diff --git a/.licenserc.yaml b/.licenserc.yaml index 2fbbafe..41ed9bc 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -61,6 +61,7 @@ header: # `header` section is configurations for source codes license header. - "**/*.lds" - "**/lib64" - ".github/**" + - "app.conf" comment: on-failure # on what condition license-eye will comment on the pull request, `on-failure`, `always`, `never`. # license-location-threshold specifies the index threshold where the license header can be located, # after all, a "header" cannot be TOO far from the file start. diff --git a/agent_loop/BUILD.gn b/agent_loop/BUILD.gn new file mode 100644 index 0000000..4d0ed33 --- /dev/null +++ b/agent_loop/BUILD.gn @@ -0,0 +1,73 @@ +# Copyright (c) 2025 vivo Mobile Communication Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import("//build/boards/${board}.gni") +import("//build/templates/reload_autoconf_and_build.gni") +import("//build/templates/rust.gni") +import("//build/toolchain/blueos.gni") + +reload_autoconf_and_build("agent_loop") { + app_conf = [ "app.conf" ] + crate_name = "agent_loop" + crate_type = "bin" + + sources = [ "src/main.rs" ] + deps = [ + "//kernel/adapter/esp_radio_sys", + "//kernel/kconfig:blueos_kconfig", + "//kernel/rsrt:rsrt_std", + "//libc", + "//librs", + "//external/vendor/embedded-tls-0.16.3:embedded_tls", + "//external/vendor/embedded-io-adapters-0.6.2:embedded_io_adapters", + "//external/vendor/embedded-io-0.6.1:embedded_io", + "//external/vendor/fastrand-2.3.0:fastrand", + "//external/vendor/rand_core-0.6.4:rand_core", + "//external/vendor/serde-1.0.228:serde", + "//external/vendor/serde_json-1.0.135:serde_json", + "//external/vendor/httparse-1.10.1:httparse", + ] + linker_script = default_linker_script + configs = [ "//build/boards/${board}:kernel_config" ] + + wifi_ldflags = [ + "-L" + rebase_path("${root_build_dir}/obj/external/vendor/esp-phy-0.2.0"), + "-L" + + rebase_path("${root_build_dir}/obj/external/vendor/esp-radio-0.18.0"), + "-L" + rebase_path("//external/vendor/esp-wifi-sys-esp32c3-0.2.0/libs"), + "-L" + rebase_path("//external/vendor/esp-rom-sys-0.1.4/ld/esp32c3"), + "-L" + rebase_path("//external/vendor/esp-radio-0.18.0"), + "-L" + rebase_path("//external/vendor/esp-phy-0.2.0"), + "-L" + rebase_path("//external/vendor/embedded-tls-0.16.3"), + "-lesp-phy", + "-lesp_rom_sys", + "-lbtbb", + "-lbtdm_app", + "-lcoexist", + "-lespnow", + "-lmesh", + "-lnet80211", + "-lcore", + "-lsmartconfig", + "-lwapi", + "-lwpa_supplicant", + "-lregulatory", + "-lphy", + "-lpp", + ] + rustflags = [] + foreach(item, wifi_ldflags) { + rustflags += [ "-Clink-arg=${item}" ] + } +} diff --git a/agent_loop/app.conf b/agent_loop/app.conf new file mode 100644 index 0000000..bb3f7a2 --- /dev/null +++ b/agent_loop/app.conf @@ -0,0 +1,10 @@ +CONFIG_ENABLE_NET=y +CONFIG_ENABLE_VFS=y +CONFIG_KERNEL_ASYNC=y +CONFIG_NET_ROUTER_IP="10.212.67.145" +CONFIG_NET_STATIC_IP="10.212.67.155" +CONFIG_ENABLE_WLAN=y +# Change the following two lines to your Wi-Fi SSID and password. +CONFIG_WLAN_SSID="test_esp" +CONFIG_WLAN_PASSWORD="88888888" +CONFIG_LOG_LEVEL_TRACE=y diff --git a/agent_loop/src/api/chat.rs b/agent_loop/src/api/chat.rs new file mode 100755 index 0000000..78f7992 --- /dev/null +++ b/agent_loop/src/api/chat.rs @@ -0,0 +1,330 @@ +use alloc::{collections::BTreeMap, string::String, vec::Vec}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum MessageRole { + Developer, + System, + User, + Assistant, + Tool, + Function, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum ChatContent { + Text(String), + Parts(Vec), +} + +impl From for ChatContent { + fn from(value: String) -> Self { + Self::Text(value) + } +} + +impl From<&str> for ChatContent { + fn from(value: &str) -> Self { + Self::Text(String::from(value)) + } +} + +impl From> for ChatContent { + fn from(value: Vec) -> Self { + Self::Parts(value) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChatMessage { + pub role: MessageRole, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl ChatMessage { + pub fn new(role: MessageRole, content: impl Into) -> Self { + Self { + role, + content: Some(content.into()), + name: None, + tool_calls: None, + tool_call_id: None, + extra: BTreeMap::new(), + } + } + + pub fn developer(content: impl Into) -> Self { + Self::new(MessageRole::Developer, content) + } + + pub fn system(content: impl Into) -> Self { + Self::new(MessageRole::System, content) + } + + pub fn user(content: impl Into) -> Self { + Self::new(MessageRole::User, content) + } + + pub fn assistant(content: impl Into) -> Self { + Self::new(MessageRole::Assistant, content) + } + + pub fn tool(tool_call_id: impl Into, content: impl Into) -> Self { + let mut message = Self::new(MessageRole::Tool, content); + message.tool_call_id = Some(tool_call_id.into()); + message + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ToolCall { + pub id: String, + #[serde(rename = "type")] + pub kind: String, + pub function: ToolCallFunction, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ToolCallFunction { + pub name: String, + pub arguments: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ReasoningEffort { + None, + Minimal, + Low, + Medium, + High, + Xhigh, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum StopSequence { + One(String), + Many(Vec), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChatCompletionRequest { + pub model: String, + pub messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub audio: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub frequency_penalty: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub logit_bias: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub logprobs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_completion_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub modalities: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub n: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parallel_tool_calls: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prediction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub presence_penalty: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub response_format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stop: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub store: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_options: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_logprobs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub web_search_options: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl ChatCompletionRequest { + pub fn new(model: impl Into, messages: Vec) -> Self { + Self { + model: model.into(), + messages, + audio: None, + frequency_penalty: None, + logit_bias: None, + logprobs: None, + max_completion_tokens: None, + max_tokens: None, + metadata: None, + modalities: None, + n: None, + parallel_tool_calls: None, + prediction: None, + presence_penalty: None, + reasoning_effort: None, + response_format: None, + seed: None, + service_tier: None, + stop: None, + store: None, + stream: None, + stream_options: None, + temperature: None, + tool_choice: None, + tools: None, + top_logprobs: None, + top_p: None, + user: None, + web_search_options: None, + extra: BTreeMap::new(), + } + } + + pub fn max_completion_tokens(mut self, max_completion_tokens: u32) -> Self { + self.max_completion_tokens = Some(max_completion_tokens); + self + } + + pub fn temperature(mut self, temperature: f32) -> Self { + self.temperature = Some(temperature); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChatCompletionResponse { + pub id: String, + pub object: String, + pub created: u64, + pub model: String, + pub choices: Vec, + #[serde(default)] + pub usage: Option, + #[serde(default)] + pub service_tier: Option, + #[serde(default)] + pub system_fingerprint: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChatCompletionChoice { + pub index: u32, + pub message: ChatResponseMessage, + #[serde(default)] + pub finish_reason: Option, + #[serde(default)] + pub logprobs: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChatResponseMessage { + pub role: MessageRole, + #[serde(default)] + pub content: Option, + #[serde(default)] + pub refusal: Option, + #[serde(default)] + pub tool_calls: Option>, + #[serde(default, alias = "reasoning")] + pub reasoning_content: Option, + #[serde(default)] + pub annotations: Option, + #[serde(default)] + pub audio: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ChatUsage { + pub prompt_tokens: u64, + pub completion_tokens: u64, + pub total_tokens: u64, + #[serde(default)] + pub prompt_tokens_details: Option, + #[serde(default)] + pub completion_tokens_details: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChatCompletionChunk { + pub id: String, + pub object: String, + pub created: u64, + pub model: String, + pub choices: Vec, + #[serde(default)] + pub usage: Option, + #[serde(default)] + pub service_tier: Option, + #[serde(default)] + pub system_fingerprint: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChatCompletionChunkChoice { + pub index: u32, + pub delta: ChatCompletionDelta, + #[serde(default)] + pub finish_reason: Option, + #[serde(default)] + pub logprobs: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChatCompletionDelta { + #[serde(default)] + pub role: Option, + #[serde(default)] + pub content: Option, + #[serde(default)] + pub refusal: Option, + #[serde(default)] + pub tool_calls: Option>, + #[serde(default, alias = "reasoning")] + pub reasoning_content: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} diff --git a/agent_loop/src/api/common.rs b/agent_loop/src/api/common.rs new file mode 100755 index 0000000..3ad016a --- /dev/null +++ b/agent_loop/src/api/common.rs @@ -0,0 +1,9 @@ +use alloc::string::String; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DeletionStatus { + pub id: String, + pub object: String, + pub deleted: bool, +} diff --git a/agent_loop/src/api/embeddings.rs b/agent_loop/src/api/embeddings.rs new file mode 100755 index 0000000..b04bba4 --- /dev/null +++ b/agent_loop/src/api/embeddings.rs @@ -0,0 +1,92 @@ +use alloc::{collections::BTreeMap, string::String, vec::Vec}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum EmbeddingInput { + Text(String), + Texts(Vec), + Tokens(Vec), + TokenArrays(Vec>), +} + +impl From for EmbeddingInput { + fn from(value: String) -> Self { + Self::Text(value) + } +} + +impl From<&str> for EmbeddingInput { + fn from(value: &str) -> Self { + Self::Text(String::from(value)) + } +} + +impl From> for EmbeddingInput { + fn from(value: Vec) -> Self { + Self::Texts(value) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum EncodingFormat { + Float, + Base64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EmbeddingRequest { + pub model: String, + pub input: EmbeddingInput, + #[serde(skip_serializing_if = "Option::is_none")] + pub encoding_format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dimensions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl EmbeddingRequest { + pub fn new(model: impl Into, input: impl Into) -> Self { + Self { + model: model.into(), + input: input.into(), + encoding_format: None, + dimensions: None, + user: None, + extra: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EmbeddingResponse { + pub object: String, + pub data: Vec, + pub model: String, + pub usage: EmbeddingUsage, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EmbeddingData { + pub object: String, + pub embedding: EmbeddingVector, + pub index: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum EmbeddingVector { + Float(Vec), + Base64(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EmbeddingUsage { + pub prompt_tokens: u64, + pub total_tokens: u64, +} diff --git a/agent_loop/src/api/mod.rs b/agent_loop/src/api/mod.rs new file mode 100755 index 0000000..c6299a2 --- /dev/null +++ b/agent_loop/src/api/mod.rs @@ -0,0 +1,5 @@ +pub mod chat; +pub mod common; +pub mod embeddings; +pub mod models; +pub mod responses; diff --git a/agent_loop/src/api/models.rs b/agent_loop/src/api/models.rs new file mode 100755 index 0000000..5504b10 --- /dev/null +++ b/agent_loop/src/api/models.rs @@ -0,0 +1,19 @@ +use alloc::{collections::BTreeMap, string::String, vec::Vec}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ModelList { + pub object: String, + pub data: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Model { + pub id: String, + pub object: String, + pub created: u64, + pub owned_by: String, + #[serde(flatten)] + pub extra: BTreeMap, +} diff --git a/agent_loop/src/api/responses.rs b/agent_loop/src/api/responses.rs new file mode 100755 index 0000000..a7c3871 --- /dev/null +++ b/agent_loop/src/api/responses.rs @@ -0,0 +1,304 @@ +use alloc::{collections::BTreeMap, string::String, vec::Vec}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum ResponseInput { + Text(String), + Items(Vec), +} + +impl From for ResponseInput { + fn from(value: String) -> Self { + Self::Text(value) + } +} + +impl From<&str> for ResponseInput { + fn from(value: &str) -> Self { + Self::Text(String::from(value)) + } +} + +impl From> for ResponseInput { + fn from(value: Vec) -> Self { + Self::Items(value) + } +} + +/// Request body for `POST /v1/responses`. +/// +/// Union-heavy fields use `serde_json::Value` so new OpenAI tool and content +/// variants remain usable without requiring a crate release. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct CreateResponseRequest { + pub model: String, + pub input: ResponseInput, + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tool_calls: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub parallel_tool_calls: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub previous_response_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_options: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_retention: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_identifier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub store: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_options: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_logprobs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub truncation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl CreateResponseRequest { + pub fn new(model: impl Into, input: impl Into) -> Self { + Self { + model: model.into(), + input: input.into(), + background: None, + conversation: None, + include: None, + instructions: None, + max_output_tokens: None, + max_tool_calls: None, + metadata: None, + parallel_tool_calls: None, + previous_response_id: None, + prompt: None, + prompt_cache_key: None, + prompt_cache_options: None, + prompt_cache_retention: None, + reasoning: None, + safety_identifier: None, + service_tier: None, + store: None, + stream: None, + stream_options: None, + temperature: None, + text: None, + tool_choice: None, + tools: None, + top_logprobs: None, + top_p: None, + truncation: None, + user: None, + extra: BTreeMap::new(), + } + } + + pub fn instructions(mut self, instructions: impl Into) -> Self { + self.instructions = Some(instructions.into()); + self + } + + pub fn max_output_tokens(mut self, max_output_tokens: u32) -> Self { + self.max_output_tokens = Some(max_output_tokens); + self + } + + pub fn previous_response_id(mut self, response_id: impl Into) -> Self { + self.previous_response_id = Some(response_id.into()); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ResponseObject { + pub id: String, + pub object: String, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub completed_at: Option, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub incomplete_details: Option, + #[serde(default)] + pub instructions: Option, + #[serde(default)] + pub max_output_tokens: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub output: Vec, + #[serde(default)] + pub parallel_tool_calls: Option, + #[serde(default)] + pub previous_response_id: Option, + #[serde(default)] + pub reasoning: Option, + #[serde(default)] + pub service_tier: Option, + #[serde(default)] + pub store: Option, + #[serde(default)] + pub temperature: Option, + #[serde(default)] + pub text: Option, + #[serde(default)] + pub tool_choice: Option, + #[serde(default)] + pub tools: Vec, + #[serde(default)] + pub top_p: Option, + #[serde(default)] + pub truncation: Option, + #[serde(default)] + pub usage: Option, + #[serde(default)] + pub metadata: Option>, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl ResponseObject { + pub fn output_text(&self) -> String { + let mut output = String::new(); + for item in &self.output { + for part in &item.content { + if let Some(text) = &part.text { + output.push_str(text); + } + } + } + output + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ResponseOutputItem { + #[serde(rename = "type")] + pub kind: String, + #[serde(default)] + pub id: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub role: Option, + #[serde(default)] + pub content: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ResponseContentPart { + #[serde(rename = "type")] + pub kind: String, + #[serde(default)] + pub text: Option, + #[serde(default)] + pub annotations: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ResponseUsage { + pub input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, + #[serde(default)] + pub input_tokens_details: Option, + #[serde(default)] + pub output_tokens_details: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ResponseStreamEvent { + #[serde(rename = "type")] + pub kind: String, + #[serde(default)] + pub sequence_number: Option, + #[serde(default)] + pub delta: Option, + #[serde(default)] + pub response: Option, + #[serde(default)] + pub item: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct CountTokensRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub previous_response_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl CountTokensRequest { + pub fn new(model: impl Into, input: impl Into) -> Self { + Self { + model: Some(model.into()), + input: Some(input.into()), + instructions: None, + conversation: None, + previous_response_id: None, + tools: None, + extra: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CountTokensResponse { + pub input_tokens: u64, +} diff --git a/agent_loop/src/client.rs b/agent_loop/src/client.rs new file mode 100755 index 0000000..ff488a1 --- /dev/null +++ b/agent_loop/src/client.rs @@ -0,0 +1,661 @@ +use alloc::{format, string::String, vec::Vec}; +use core::fmt::Write; +use serde::{de::DeserializeOwned, Serialize}; + +use crate::{ + api::{ + chat::{ChatCompletionRequest, ChatCompletionResponse}, + common::DeletionStatus, + embeddings::{EmbeddingRequest, EmbeddingResponse}, + models::{Model, ModelList}, + responses::{ + CountTokensRequest, CountTokensResponse, CreateResponseRequest, ResponseObject, + }, + }, + error::{ApiErrorEnvelope, ConfigError, Error}, + http::{ + self, Header, HttpBody, HttpError, HttpResponseBody, Method, Request, Response, Scheme, + SocketTransport, + }, + sse::ApiStream, +}; + +pub const DEFAULT_API_ENDPOINT: &str = "https://api.openai.com/v1"; +const DEFAULT_MAX_RESPONSE_BODY_SIZE: usize = 1024 * 1024; +const DEFAULT_MAX_RESPONSE_HEADER_SIZE: usize = 16 * 1024; +const READ_BUFFER_SIZE: usize = 1024; + +#[derive(Debug)] +pub struct ApiResponse { + pub status: u16, + pub headers: Vec
, + pub data: T, +} + +impl ApiResponse { + pub fn into_inner(self) -> T { + self.data + } + + pub fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|header| header.is_name(name)) + .map(|header| header.value.as_str()) + } +} + +#[derive(Debug, Clone)] +struct Endpoint { + scheme: Scheme, + host: String, + port: u16, + authority: String, + base_path: String, +} + +impl Endpoint { + fn parse(endpoint: &str) -> Result { + if endpoint.is_empty() { + return Err(ConfigError::EmptyEndpoint); + } + if contains_line_break(endpoint) || endpoint.contains(['?', '#', '@']) { + return Err(ConfigError::InvalidEndpoint); + } + + let (scheme, rest) = endpoint + .split_once("://") + .ok_or(ConfigError::InvalidEndpoint)?; + let scheme = match scheme { + "http" => Scheme::Http, + "https" => Scheme::Https, + _ => return Err(ConfigError::InvalidEndpoint), + }; + let (authority, path) = match rest.find('/') { + Some(index) => (&rest[..index], &rest[index..]), + None => (rest, ""), + }; + if authority.is_empty() { + return Err(ConfigError::InvalidEndpoint); + } + + let (host, port) = parse_authority(authority, scheme.default_port())?; + let mut base_path = String::from(path.trim_end_matches('/')); + if base_path == "/" { + base_path.clear(); + } + + Ok(Self { + scheme, + host, + port, + authority: String::from(authority), + base_path, + }) + } +} + +pub struct ClientBuilder { + transport: T, + endpoint: String, + api_key: Option, + organization: Option, + project: Option, + headers: Vec
, + host_header: Option, + max_response_body_size: usize, + max_response_header_size: usize, +} + +impl ClientBuilder { + pub fn new(transport: T) -> Self { + Self { + transport, + endpoint: String::from(DEFAULT_API_ENDPOINT), + api_key: None, + organization: None, + project: None, + headers: Vec::new(), + host_header: None, + max_response_body_size: DEFAULT_MAX_RESPONSE_BODY_SIZE, + max_response_header_size: DEFAULT_MAX_RESPONSE_HEADER_SIZE, + } + } + + pub fn with_api_key(mut self, api_key: impl Into) -> Self { + self.api_key = Some(api_key.into()); + self + } + + pub fn with_endpoint(mut self, endpoint: impl Into) -> Self { + self.endpoint = endpoint.into(); + self + } + + pub fn with_organization(mut self, organization: impl Into) -> Self { + self.organization = Some(organization.into()); + self + } + + pub fn with_project(mut self, project: impl Into) -> Self { + self.project = Some(project.into()); + self + } + + pub fn with_header(mut self, name: impl Into, value: impl Into) -> Self { + upsert_header(&mut self.headers, Header::new(name, value)); + self + } + + pub fn with_host_header(mut self, host: impl Into) -> Self { + self.host_header = Some(host.into()); + self + } + + pub fn with_max_response_body_size(mut self, bytes: usize) -> Self { + self.max_response_body_size = bytes; + self + } + + pub fn with_max_response_header_size(mut self, bytes: usize) -> Self { + self.max_response_header_size = bytes; + self + } + + pub fn build(self) -> Result, ConfigError> { + let endpoint = Endpoint::parse(self.endpoint.trim_end_matches('/'))?; + + if let Some(api_key) = &self.api_key { + if contains_line_break(api_key) { + return Err(ConfigError::InvalidApiKey); + } + } + for header in &self.headers { + validate_header(header)?; + } + if let Some(organization) = &self.organization { + validate_header(&Header::new("OpenAI-Organization", organization.clone()))?; + } + if let Some(project) = &self.project { + validate_header(&Header::new("OpenAI-Project", project.clone()))?; + } + + Ok(Client { + transport: self.transport, + endpoint, + api_key: self.api_key, + organization: self.organization, + project: self.project, + headers: self.headers, + host_header: self.host_header, + max_response_body_size: self.max_response_body_size, + max_response_header_size: self.max_response_header_size, + }) + } +} + +pub struct Client { + transport: T, + endpoint: Endpoint, + api_key: Option, + organization: Option, + project: Option, + headers: Vec
, + host_header: Option, + max_response_body_size: usize, + max_response_header_size: usize, +} + +impl Client { + pub fn builder(transport: T) -> ClientBuilder { + ClientBuilder::new(transport) + } + + pub fn new(transport: T, api_key: impl Into) -> Result { + Self::builder(transport).with_api_key(api_key).build() + } + + pub fn endpoint(&self) -> String { + let mut endpoint = format!( + "{}://{}", + match self.endpoint.scheme { + Scheme::Http => "http", + Scheme::Https => "https", + }, + self.endpoint.authority + ); + endpoint.push_str(&self.endpoint.base_path); + endpoint + } + + pub fn transport_mut(&mut self) -> &mut T { + &mut self.transport + } + + pub fn into_transport(self) -> T { + self.transport + } +} + +impl Client { + pub fn get_json(&mut self, path: &str) -> Result, Error> + where + R: DeserializeOwned, + { + let response = self.send_buffered(Method::Get, path, &[], false)?; + decode_json(response) + } + + pub fn post_json( + &mut self, + path: &str, + request: &Q, + ) -> Result, Error> + where + Q: Serialize + ?Sized, + R: DeserializeOwned, + { + let body = serde_json::to_vec(request).map_err(Error::Serialize)?; + let response = self.send_buffered(Method::Post, path, &body, true)?; + decode_json(response) + } + + pub fn delete_json(&mut self, path: &str) -> Result, Error> + where + R: DeserializeOwned, + { + let response = self.send_buffered(Method::Delete, path, &[], false)?; + decode_json(response) + } + + pub fn send_raw( + &mut self, + method: Method, + path: &str, + body: &[u8], + content_type: Option<&str>, + ) -> Result>, Error> { + self.send_buffered_with_content_type(method, path, body, content_type) + } + + pub fn post_json_stream<'a, Q>( + &'a mut self, + path: &str, + request: &Q, + ) -> Result>>, Error> + where + Q: Serialize + ?Sized, + { + let body = stream_json_body(request)?; + self.send_stream(path, &body) + } + + pub fn create_response( + &mut self, + request: &CreateResponseRequest, + ) -> Result, Error> { + self.post_json("responses", request) + } + + pub fn create_response_stream<'a>( + &'a mut self, + request: &CreateResponseRequest, + ) -> Result>>, Error> { + self.post_json_stream("responses", request) + } + + pub fn retrieve_response( + &mut self, + response_id: &str, + ) -> Result, Error> { + self.get_json(&format!("responses/{}", encode_path_segment(response_id))) + } + + pub fn delete_response( + &mut self, + response_id: &str, + ) -> Result, Error> { + self.delete_json(&format!("responses/{}", encode_path_segment(response_id))) + } + + pub fn cancel_response( + &mut self, + response_id: &str, + ) -> Result, Error> { + self.post_json( + &format!("responses/{}/cancel", encode_path_segment(response_id)), + &serde_json::json!({}), + ) + } + + pub fn count_response_input_tokens( + &mut self, + request: &CountTokensRequest, + ) -> Result, Error> { + self.post_json("responses/input_tokens", request) + } + + pub fn chat_completion( + &mut self, + request: &ChatCompletionRequest, + ) -> Result, Error> { + self.post_json("chat/completions", request) + } + + pub fn chat_completion_stream<'a>( + &'a mut self, + request: &ChatCompletionRequest, + ) -> Result>>, Error> { + self.post_json_stream("chat/completions", request) + } + + pub fn create_embedding( + &mut self, + request: &EmbeddingRequest, + ) -> Result, Error> { + self.post_json("embeddings", request) + } + + pub fn list_models(&mut self) -> Result, Error> { + self.get_json("models") + } + + pub fn retrieve_model( + &mut self, + model_id: &str, + ) -> Result, Error> { + self.get_json(&format!("models/{}", encode_path_segment(model_id))) + } + + fn send_buffered( + &mut self, + method: Method, + path: &str, + body: &[u8], + is_json: bool, + ) -> Result>, Error> { + self.send_buffered_with_content_type( + method, + path, + body, + is_json.then_some("application/json"), + ) + } + + fn send_buffered_with_content_type( + &mut self, + method: Method, + path: &str, + body: &[u8], + content_type: Option<&str>, + ) -> Result>, Error> { + let max_response_body_size = self.max_response_body_size; + let response = self.send_http( + method, + path, + body, + content_type, + false, + method == Method::Post || content_type.is_some() || !body.is_empty(), + )?; + let status = response.status; + let headers = response.headers; + let body = read_body(response.body, max_response_body_size)?; + if !(200..300).contains(&status) { + return Err(api_error(status, body)); + } + Ok(ApiResponse { + status, + headers, + data: body, + }) + } + + fn send_stream<'a>( + &'a mut self, + path: &str, + body: &[u8], + ) -> Result>>, Error> { + let max_response_body_size = self.max_response_body_size; + let response = self.send_http( + Method::Post, + path, + body, + Some("application/json"), + true, + true, + )?; + if !(200..300).contains(&response.status) { + let status = response.status; + let body = read_body(response.body, max_response_body_size)?; + return Err(api_error(status, body)); + } + Ok(ApiStream::new( + response.status, + response.headers, + response.body, + )) + } + + fn send_http<'a>( + &'a mut self, + method: Method, + path: &str, + body: &[u8], + content_type: Option<&str>, + stream: bool, + body_present: bool, + ) -> Result>>, Error> { + let target = self.build_target(path)?; + let headers = self.build_headers(content_type, stream); + let host = self.endpoint.host.clone(); + let authority = self + .host_header + .clone() + .unwrap_or_else(|| self.endpoint.authority.clone()); + let port = self.endpoint.port; + let scheme = self.endpoint.scheme; + let socket = self + .transport + .connect(&host, port, scheme) + .map_err(|error| Error::Http(HttpError::Transport(error)))?; + http::send_request( + socket, + Request { + method, + target: &target, + host: &authority, + headers: &headers, + body: body_present.then_some(body), + }, + self.max_response_header_size, + ) + .map_err(Error::Http) + } + + fn build_target(&self, path: &str) -> Result> { + if contains_line_break(path) { + return Err(Error::InvalidPath); + } + let path = path.trim_start_matches('/'); + if self.endpoint.base_path.is_empty() { + Ok(format!("/{path}")) + } else if path.is_empty() { + Ok(self.endpoint.base_path.clone()) + } else { + Ok(format!("{}/{path}", self.endpoint.base_path)) + } + } + + fn build_headers(&self, content_type: Option<&str>, stream: bool) -> Vec
{ + let mut headers = self.headers.clone(); + upsert_header( + &mut headers, + Header::new( + "Accept", + if stream { + "text/event-stream" + } else { + "application/json" + }, + ), + ); + if let Some(content_type) = content_type { + upsert_header(&mut headers, Header::new("Content-Type", content_type)); + } + if let Some(api_key) = &self.api_key { + upsert_header( + &mut headers, + Header::new("Authorization", format!("Bearer {api_key}")), + ); + } + if let Some(organization) = &self.organization { + upsert_header( + &mut headers, + Header::new("OpenAI-Organization", organization.clone()), + ); + } + if let Some(project) = &self.project { + upsert_header(&mut headers, Header::new("OpenAI-Project", project.clone())); + } + headers + } +} + +fn stream_json_body(request: &Q) -> Result, Error> { + let mut value = serde_json::to_value(request).map_err(Error::Serialize)?; + let object = value.as_object_mut().ok_or_else(|| { + Error::Serialize(::custom( + "streaming request must serialize to a JSON object", + )) + })?; + object.insert(String::from("stream"), serde_json::Value::Bool(true)); + serde_json::to_vec(&value).map_err(Error::Serialize) +} + +fn read_body(mut body: B, limit: usize) -> Result, Error> +where + B: HttpBody>, +{ + let mut output = Vec::new(); + let mut chunk = [0u8; READ_BUFFER_SIZE]; + let mut next_milestone = 4096usize; + loop { + let count = body.read(&mut chunk).map_err(Error::Http)?; + if count == 0 { + println!("[http] body complete: {} bytes", output.len()); + return Ok(output); + } + if output.len().saturating_add(count) > limit { + return Err(Error::ResponseTooLarge { limit }); + } + output.extend_from_slice(&chunk[..count]); + if output.len() >= next_milestone { + println!("[http] body: {} bytes received...", output.len()); + next_milestone = output.len() + 4096; + } + } +} + +fn decode_json(response: ApiResponse>) -> Result, Error> +where + T: DeserializeOwned, +{ + match serde_json::from_slice(&response.data) { + Ok(data) => Ok(ApiResponse { + status: response.status, + headers: response.headers, + data, + }), + Err(source) => Err(Error::Deserialize { + source, + body: response.data, + }), + } +} + +fn api_error(status: u16, body: Vec) -> Error { + let error = serde_json::from_slice::(&body) + .ok() + .map(|envelope| envelope.error); + Error::Api { + status, + error, + body, + } +} + +fn parse_authority(authority: &str, default_port: u16) -> Result<(String, u16), ConfigError> { + if authority.starts_with('[') { + let end = authority.find(']').ok_or(ConfigError::InvalidEndpoint)?; + let host = &authority[1..end]; + let suffix = &authority[end + 1..]; + let port = if suffix.is_empty() { + default_port + } else { + suffix + .strip_prefix(':') + .ok_or(ConfigError::InvalidEndpoint)? + .parse() + .map_err(|_| ConfigError::InvalidEndpoint)? + }; + if host.is_empty() { + return Err(ConfigError::InvalidEndpoint); + } + return Ok((String::from(host), port)); + } + + let (host, port) = match authority.rsplit_once(':') { + Some((host, port)) if !host.contains(':') => ( + host, + port.parse().map_err(|_| ConfigError::InvalidEndpoint)?, + ), + _ => (authority, default_port), + }; + if host.is_empty() || host.bytes().any(|byte| byte.is_ascii_whitespace()) { + return Err(ConfigError::InvalidEndpoint); + } + Ok((String::from(host), port)) +} + +fn validate_header(header: &Header) -> Result<(), ConfigError> { + if header.name.is_empty() + || !header + .name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err(ConfigError::InvalidHeaderName(header.name.clone())); + } + if contains_line_break(&header.value) { + return Err(ConfigError::InvalidHeaderValue(header.name.clone())); + } + Ok(()) +} + +fn contains_line_break(value: &str) -> bool { + value.bytes().any(|byte| byte == b'\r' || byte == b'\n') +} + +fn upsert_header(headers: &mut Vec
, new_header: Header) { + if let Some(header) = headers + .iter_mut() + .find(|header| header.is_name(&new_header.name)) + { + *header = new_header; + } else { + headers.push(new_header); + } +} + +fn encode_path_segment(segment: &str) -> String { + let mut encoded = String::new(); + for byte in segment.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + let _ = write!(&mut encoded, "%{byte:02X}"); + } + } + encoded +} diff --git a/agent_loop/src/error.rs b/agent_loop/src/error.rs new file mode 100755 index 0000000..0cf6226 --- /dev/null +++ b/agent_loop/src/error.rs @@ -0,0 +1,99 @@ +use alloc::{string::String, vec::Vec}; +use core::fmt; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::http::HttpError; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiError { + pub message: String, + #[serde(rename = "type", default)] + pub kind: Option, + #[serde(default)] + pub param: Option, + #[serde(default)] + pub code: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ApiErrorEnvelope { + pub error: ApiError, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigError { + EmptyEndpoint, + InvalidEndpoint, + InvalidApiKey, + InvalidHeaderName(String), + InvalidHeaderValue(String), +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyEndpoint => f.write_str("API endpoint cannot be empty"), + Self::InvalidEndpoint => f.write_str("API endpoint contains an invalid character"), + Self::InvalidApiKey => f.write_str("API key contains an invalid character"), + Self::InvalidHeaderName(name) => write!(f, "invalid HTTP header name: {name}"), + Self::InvalidHeaderValue(name) => { + write!(f, "HTTP header {name} contains an invalid value") + } + } + } +} + +#[derive(Debug)] +pub enum Error { + Http(HttpError), + Serialize(serde_json::Error), + Deserialize { + source: serde_json::Error, + body: Vec, + }, + Api { + status: u16, + error: Option, + body: Vec, + }, + ResponseTooLarge { + limit: usize, + }, + InvalidPath, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Http(error) => write!(f, "HTTP error: {error}"), + Self::Serialize(error) => write!(f, "failed to serialize request JSON: {error}"), + Self::Deserialize { source, .. } => { + write!(f, "failed to deserialize response JSON: {source}") + } + Self::Api { + status, + error: Some(error), + .. + } => write!(f, "OpenAI API returned HTTP {status}: {}", error.message), + Self::Api { + status, + error: None, + .. + } => write!(f, "OpenAI API returned HTTP {status}"), + Self::ResponseTooLarge { limit } => { + write!( + f, + "response body exceeded the configured {limit}-byte limit" + ) + } + Self::InvalidPath => f.write_str("request path contains an invalid character"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for ConfigError {} + +#[cfg(feature = "std")] +impl std::error::Error for Error where E: std::error::Error + 'static {} diff --git a/agent_loop/src/http.rs b/agent_loop/src/http.rs new file mode 100755 index 0000000..b225bb0 --- /dev/null +++ b/agent_loop/src/http.rs @@ -0,0 +1,610 @@ +use alloc::{string::String, vec::Vec}; +use core::{convert::Infallible, fmt, str}; +use embedded_io::{Read, Write}; + +const MAX_HEADERS: usize = 64; +const MAX_CHUNK_LINE_SIZE: usize = 128; +const MAX_TRAILER_SIZE: usize = 8 * 1024; +const READ_BUFFER_SIZE: usize = 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scheme { + Http, + Https, +} + +impl Scheme { + pub const fn default_port(self) -> u16 { + match self { + Self::Http => 80, + Self::Https => 443, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Method { + Get, + Post, + Delete, +} + +impl Method { + pub const fn as_str(self) -> &'static str { + match self { + Self::Get => "GET", + Self::Post => "POST", + Self::Delete => "DELETE", + } + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct Header { + pub name: String, + pub value: String, +} + +impl Header { + pub fn new(name: impl Into, value: impl Into) -> Self { + Self { + name: name.into(), + value: value.into(), + } + } + + pub fn is_name(&self, name: &str) -> bool { + self.name.eq_ignore_ascii_case(name) + } +} + +impl fmt::Debug for Header { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let value = if self.is_name("authorization") { + "" + } else { + &self.value + }; + + f.debug_struct("Header") + .field("name", &self.name) + .field("value", &value) + .finish() + } +} + +/// A complete HTTP request at the socket boundary. +pub struct Request<'a> { + pub method: Method, + /// Origin-form request target, for example `/v1/responses?limit=10`. + pub target: &'a str, + /// Value for the HTTP `Host` header. + pub host: &'a str, + pub headers: &'a [Header], + /// `None` omits `Content-Length`; `Some(&[])` sends `Content-Length: 0`. + pub body: Option<&'a [u8]>, +} + +impl fmt::Debug for Request<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Request") + .field("method", &self.method) + .field("target", &self.target) + .field("host", &self.host) + .field("headers", &self.headers) + .field("body_len", &self.body.map(<[u8]>::len)) + .finish() + } +} + +#[derive(Debug)] +pub struct Response { + pub status: u16, + pub headers: Vec
, + pub body: B, +} + +impl Response { + pub const fn is_success(&self) -> bool { + self.status >= 200 && self.status < 300 + } +} + +/// Supplies an already-connected blocking byte stream. +/// +/// Implementations own DNS, TCP, timeouts, and optional TLS. The API and HTTP +/// layers only depend on synchronous `embedded_io::Read + Write` operations. +pub trait SocketTransport { + type Error: embedded_io::Error; + type Socket<'a>: Read + Write + where + Self: 'a; + + fn connect<'a>( + &'a mut self, + host: &str, + port: u16, + scheme: Scheme, + ) -> Result, Self::Error>; +} + +/// A response body that can be consumed incrementally without async polling. +pub trait HttpBody { + type Error; + + /// Returns `0` when the HTTP message body is complete. + fn read(&mut self, buffer: &mut [u8]) -> Result; +} + +#[derive(Debug)] +pub enum HttpError { + Transport(E), + InvalidRequest, + InvalidResponse, + ConnectionClosed, + HeaderTooLarge { limit: usize }, + TooManyHeaders, + InvalidContentLength, + UnsupportedTransferEncoding, + InvalidChunk, +} + +impl fmt::Display for HttpError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Transport(error) => write!(f, "socket error: {error}"), + Self::InvalidRequest => f.write_str("invalid HTTP request"), + Self::InvalidResponse => f.write_str("invalid HTTP response"), + Self::ConnectionClosed => { + f.write_str("connection closed before the HTTP message completed") + } + Self::HeaderTooLarge { limit } => { + write!(f, "HTTP headers exceeded the configured {limit}-byte limit") + } + Self::TooManyHeaders => f.write_str("HTTP response contained too many headers"), + Self::InvalidContentLength => f.write_str("invalid HTTP Content-Length header"), + Self::UnsupportedTransferEncoding => f.write_str("unsupported HTTP Transfer-Encoding"), + Self::InvalidChunk => f.write_str("invalid HTTP chunked body"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for HttpError where E: std::error::Error + 'static {} + +#[derive(Debug, Default)] +pub struct BufferedBody { + bytes: Vec, + offset: usize, +} + +impl BufferedBody { + pub fn new(bytes: impl Into>) -> Self { + Self { + bytes: bytes.into(), + offset: 0, + } + } + + pub fn into_inner(self) -> Vec { + self.bytes + } +} + +impl HttpBody for BufferedBody { + type Error = Infallible; + + fn read(&mut self, buffer: &mut [u8]) -> Result { + if buffer.is_empty() || self.offset == self.bytes.len() { + return Ok(0); + } + + let remaining = &self.bytes[self.offset..]; + let count = remaining.len().min(buffer.len()); + buffer[..count].copy_from_slice(&remaining[..count]); + self.offset += count; + Ok(count) + } +} + +#[derive(Debug, Clone, Copy)] +enum BodyFraming { + Empty, + ContentLength(usize), + Chunked(ChunkState), + UntilEof, +} + +#[derive(Debug, Clone, Copy)] +enum ChunkState { + Size, + Data(usize), + DataEnd, + Trailers(usize), + Done, +} + +/// HTTP body decoder for fixed-length, chunked, and connection-delimited bodies. +#[derive(Debug)] +pub struct HttpResponseBody { + socket: S, + prefix: Vec, + prefix_offset: usize, + framing: BodyFraming, +} + +impl HttpResponseBody { + pub fn into_inner(self) -> S { + self.socket + } +} + +impl HttpResponseBody +where + S: Read, +{ + fn read_raw(&mut self, buffer: &mut [u8]) -> Result> { + if buffer.is_empty() { + return Ok(0); + } + + if self.prefix_offset < self.prefix.len() { + let available = &self.prefix[self.prefix_offset..]; + let count = available.len().min(buffer.len()); + buffer[..count].copy_from_slice(&available[..count]); + self.prefix_offset += count; + return Ok(count); + } + + self.socket.read(buffer).map_err(HttpError::Transport) + } + + fn read_raw_exact(&mut self, buffer: &mut [u8]) -> Result<(), HttpError> { + let mut offset = 0; + while offset < buffer.len() { + let count = self.read_raw(&mut buffer[offset..])?; + if count == 0 { + return Err(HttpError::ConnectionClosed); + } + offset += count; + } + Ok(()) + } + + fn read_crlf_line(&mut self, limit: usize) -> Result, HttpError> { + let mut line = Vec::new(); + loop { + let mut byte = [0u8; 1]; + self.read_raw_exact(&mut byte)?; + if byte[0] == b'\n' { + if line.last() != Some(&b'\r') { + return Err(HttpError::InvalidChunk); + } + line.pop(); + return Ok(line); + } + if line.len() == limit { + return Err(HttpError::InvalidChunk); + } + line.push(byte[0]); + } + } + + fn read_chunk_size(&mut self) -> Result> { + let line = self.read_crlf_line(MAX_CHUNK_LINE_SIZE)?; + let size = line.split(|byte| *byte == b';').next().unwrap_or_default(); + let size = str::from_utf8(size) + .map_err(|_| HttpError::InvalidChunk)? + .trim(); + if size.is_empty() { + return Err(HttpError::InvalidChunk); + } + usize::from_str_radix(size, 16).map_err(|_| HttpError::InvalidChunk) + } + + fn read_chunked(&mut self, buffer: &mut [u8]) -> Result> { + loop { + match self.framing { + BodyFraming::Chunked(ChunkState::Size) => { + let size = self.read_chunk_size()?; + self.framing = if size == 0 { + BodyFraming::Chunked(ChunkState::Trailers(0)) + } else { + BodyFraming::Chunked(ChunkState::Data(size)) + }; + } + BodyFraming::Chunked(ChunkState::Data(remaining)) => { + let limit = buffer.len().min(remaining); + let count = self.read_raw(&mut buffer[..limit])?; + if count == 0 { + return Err(HttpError::ConnectionClosed); + } + self.framing = if count == remaining { + BodyFraming::Chunked(ChunkState::DataEnd) + } else { + BodyFraming::Chunked(ChunkState::Data(remaining - count)) + }; + return Ok(count); + } + BodyFraming::Chunked(ChunkState::DataEnd) => { + let mut crlf = [0u8; 2]; + self.read_raw_exact(&mut crlf)?; + if crlf != *b"\r\n" { + return Err(HttpError::InvalidChunk); + } + self.framing = BodyFraming::Chunked(ChunkState::Size); + } + BodyFraming::Chunked(ChunkState::Trailers(total)) => { + let line = self.read_crlf_line(MAX_TRAILER_SIZE)?; + let total = total.saturating_add(line.len() + 2); + if total > MAX_TRAILER_SIZE { + return Err(HttpError::HeaderTooLarge { + limit: MAX_TRAILER_SIZE, + }); + } + self.framing = if line.is_empty() { + BodyFraming::Chunked(ChunkState::Done) + } else { + BodyFraming::Chunked(ChunkState::Trailers(total)) + }; + } + BodyFraming::Chunked(ChunkState::Done) => return Ok(0), + _ => return Err(HttpError::InvalidChunk), + } + } + } +} + +impl HttpBody for HttpResponseBody +where + S: Read, +{ + type Error = HttpError; + + fn read(&mut self, buffer: &mut [u8]) -> Result { + if buffer.is_empty() { + return Ok(0); + } + + match self.framing { + BodyFraming::Empty => Ok(0), + BodyFraming::ContentLength(0) => Ok(0), + BodyFraming::ContentLength(remaining) => { + let limit = buffer.len().min(remaining); + let count = self.read_raw(&mut buffer[..limit])?; + if count == 0 { + return Err(HttpError::ConnectionClosed); + } + self.framing = BodyFraming::ContentLength(remaining - count); + Ok(count) + } + BodyFraming::Chunked(_) => self.read_chunked(buffer), + BodyFraming::UntilEof => self.read_raw(buffer), + } + } +} + +/// Writes one HTTP/1.1 request and parses the response from a connected socket. +pub fn send_request( + mut socket: S, + request: Request<'_>, + max_header_size: usize, +) -> Result>, HttpError> +where + S: Read + Write, +{ + write_request(&mut socket, &request)?; + socket.flush().map_err(HttpError::Transport)?; + let body_len = request.body.map(|b| b.len()).unwrap_or(0); + println!( + "[http] {} {} sent, waiting for response...{}", + request.method.as_str(), + request.target, + if body_len > 0 { + format!(" ({} bytes body)", body_len) + } else { + String::new() + } + ); + read_response(socket, max_header_size) +} + +fn write_request(socket: &mut S, request: &Request<'_>) -> Result<(), HttpError> +where + S: Write, +{ + if !request.target.starts_with('/') + || contains_line_break(request.target) + || request.host.is_empty() + || contains_line_break(request.host) + { + return Err(HttpError::InvalidRequest); + } + + write_bytes(socket, request.method.as_str().as_bytes())?; + write_bytes(socket, b" ")?; + write_bytes(socket, request.target.as_bytes())?; + write_bytes(socket, b" HTTP/1.1\r\nHost: ")?; + write_bytes(socket, request.host.as_bytes())?; + write_bytes(socket, b"\r\nConnection: close\r\n")?; + + if let Some(body) = request.body { + socket + .write_fmt(format_args!("Content-Length: {}\r\n", body.len())) + .map_err(|error| match error { + embedded_io::WriteFmtError::Other(error) => HttpError::Transport(error), + embedded_io::WriteFmtError::FmtError => HttpError::InvalidRequest, + })?; + } + + for header in request.headers { + if header.name.is_empty() + || contains_line_break(&header.name) + || contains_line_break(&header.value) + || header.is_name("host") + || header.is_name("connection") + || header.is_name("content-length") + || header.is_name("transfer-encoding") + { + return Err(HttpError::InvalidRequest); + } + write_bytes(socket, header.name.as_bytes())?; + write_bytes(socket, b": ")?; + write_bytes(socket, header.value.as_bytes())?; + write_bytes(socket, b"\r\n")?; + } + + write_bytes(socket, b"\r\n")?; + if let Some(body) = request.body { + write_bytes(socket, body)?; + } + Ok(()) +} + +fn write_bytes(socket: &mut S, bytes: &[u8]) -> Result<(), HttpError> +where + S: Write, +{ + socket.write_all(bytes).map_err(HttpError::Transport) +} + +fn read_response( + mut socket: S, + max_header_size: usize, +) -> Result>, HttpError> +where + S: Read, +{ + let mut buffer = Vec::new(); + loop { + if let Some(head) = parse_response_head(&buffer)? { + if (100..200).contains(&head.status) { + if head.status == 101 { + return Err(HttpError::InvalidResponse); + } + buffer.drain(..head.length); + continue; + } + + let prefix = buffer.split_off(head.length); + let no_body = head.status == 204 || head.status == 304; + let framing = if no_body { + BodyFraming::Empty + } else if head.chunked { + BodyFraming::Chunked(ChunkState::Size) + } else if let Some(length) = head.content_length { + if prefix.len() > length { + return Err(HttpError::InvalidResponse); + } + BodyFraming::ContentLength(length) + } else { + BodyFraming::UntilEof + }; + + let framing_desc: &str = match framing { + BodyFraming::Empty => "no body", + BodyFraming::ContentLength(n) => "content-length", + BodyFraming::Chunked(_) => "chunked", + BodyFraming::UntilEof => "until-eof", + }; + let content_length = if let BodyFraming::ContentLength(n) = framing { + format!(": {}", n) + } else { + String::new() + }; + println!( + "[http] response: {}, {} headers, {}{}", + head.status, + head.headers.len(), + framing_desc, + content_length + ); + + return Ok(Response { + status: head.status, + headers: head.headers, + body: HttpResponseBody { + socket, + prefix, + prefix_offset: 0, + framing, + }, + }); + } + + if buffer.len() >= max_header_size { + return Err(HttpError::HeaderTooLarge { + limit: max_header_size, + }); + } + let mut chunk = [0u8; READ_BUFFER_SIZE]; + let capacity = (max_header_size - buffer.len()).min(chunk.len()); + let count = socket + .read(&mut chunk[..capacity]) + .map_err(HttpError::Transport)?; + if count == 0 { + return Err(HttpError::ConnectionClosed); + } + buffer.extend_from_slice(&chunk[..count]); + } +} + +struct ParsedHead { + length: usize, + status: u16, + headers: Vec
, + content_length: Option, + chunked: bool, +} + +fn parse_response_head(buffer: &[u8]) -> Result, HttpError> { + let mut raw_headers = [httparse::EMPTY_HEADER; MAX_HEADERS]; + let mut response = httparse::Response::new(&mut raw_headers); + let length = match response.parse(buffer) { + Ok(httparse::Status::Partial) => return Ok(None), + Ok(httparse::Status::Complete(length)) => length, + Err(httparse::Error::TooManyHeaders) => return Err(HttpError::TooManyHeaders), + Err(_) => return Err(HttpError::InvalidResponse), + }; + let status = response.code.ok_or(HttpError::InvalidResponse)?; + let mut headers = Vec::new(); + let mut content_length = None; + let mut chunked = false; + + for header in response.headers { + let value = str::from_utf8(header.value).map_err(|_| HttpError::InvalidResponse)?; + headers.push(Header::new(header.name, value)); + if header.name.eq_ignore_ascii_case("content-length") { + let parsed = value + .trim() + .parse::() + .map_err(|_| HttpError::InvalidContentLength)?; + if content_length + .replace(parsed) + .is_some_and(|old| old != parsed) + { + return Err(HttpError::InvalidContentLength); + } + } else if header.name.eq_ignore_ascii_case("transfer-encoding") { + for encoding in value.split(',').map(str::trim) { + if encoding.eq_ignore_ascii_case("chunked") { + chunked = true; + } else if !encoding.eq_ignore_ascii_case("identity") { + return Err(HttpError::UnsupportedTransferEncoding); + } + } + } + } + + Ok(Some(ParsedHead { + length, + status, + headers, + content_length, + chunked, + })) +} + +fn contains_line_break(value: &str) -> bool { + value.bytes().any(|byte| byte == b'\r' || byte == b'\n') +} diff --git a/agent_loop/src/main.rs b/agent_loop/src/main.rs new file mode 100755 index 0000000..b2bc599 --- /dev/null +++ b/agent_loop/src/main.rs @@ -0,0 +1,132 @@ +extern crate alloc; +extern crate esp_radio_sys; +extern crate librs; +extern crate rsrt; + +mod api; +mod client; +mod error; +mod http; +mod sse; +mod tls; +mod wifi; + +use alloc::string::ToString; +use std::{env, io::Write as _, thread}; + +use api::chat::{ChatCompletionRequest, ChatMessage}; +use tls::EmbeddedTlsTransport; + +fn main() { + /* + thread::Builder::new() + .name("agent_loop".to_string()) + .stack_size(64 << 10) + .spawn(move || { + println!("Hello, agent_loop!"); + wifi::connect_wifi(); + main_loop(); + }) + .unwrap() + .join() + .unwrap(); + */ + + println!("Hello, agent_loop!"); + wifi::connect_wifi(); + main_loop(); +} + +fn main_loop() { + let api_key = env::var("OPENAI_API_KEY").unwrap_or_else(|_| String::from("")); + let endpoint = + //env::var("OPENAI_API_BASE").unwrap_or_else(|_| String::from("https://124.72.129.70")); + env::var("OPENAI_API_BASE").unwrap_or_else(|_| String::from("https://14.116.174.67")); + let model = env::var("OPENAI_MODEL").unwrap_or_else(|_| String::from("deepseek-chat")); + + println!("get model server info"); + + let client = match client::Client::builder( + EmbeddedTlsTransport::new().with_sni("api.deepseek.com")) + .with_endpoint(endpoint) + .with_host_header("api.deepseek.com") + .with_api_key(api_key) + .with_max_response_body_size(100 * 1024) + .build() + { + Ok(c) => c, + Err(e) => { + eprintln!("client build failed: {e}"); + return; + } + }; + + println!("init success!"); + + let mut client = client; + let args: Vec = env::args().skip(1).collect(); + if args.first().map(String::as_str) == Some("--once") { + let prompt = match args.get(1) { + Some(p) => p, + None => { + eprintln!("--once requires a prompt"); + return; + } + }; + match run_turn(&mut client, &model, &mut Vec::new(), prompt) { + Ok(reply) => println!("{reply}"), + Err(e) => eprintln!("error: {e}"), + } + return; + } + + println!("first run"); + + let mut messages = Vec::new(); + println!("Model: {model}. Enter /quit to exit."); + loop { + print!("you> "); + let _ = std::io::stdout().flush(); + let mut input = String::new(); + if std::io::stdin().read_line(&mut input).unwrap_or(0) == 0 { + break; + } + let input = input.trim(); + if input == "/quit" || input == "/exit" { + break; + } + if input.is_empty() { + continue; + } + + match run_turn(&mut client, &model, &mut messages, input) { + Ok(reply) => println!("assistant> {reply}"), + Err(e) => eprintln!("request failed: {e}"), + } + } +} + +fn run_turn( + client: &mut client::Client, + model: &str, + messages: &mut Vec, + prompt: &str, +) -> Result { + messages.push(ChatMessage::user(prompt)); + let request = ChatCompletionRequest::new(model, messages.clone()); + let response = match client.chat_completion(&request) { + Ok(response) => response, + Err(error) => { + messages.pop(); + return Err(error.to_string()); + } + }; + let reply = response + .data + .choices + .first() + .and_then(|choice| choice.message.content.clone()) + .ok_or_else(|| "response did not contain assistant text".to_string())?; + messages.push(ChatMessage::assistant(reply.clone())); + Ok(reply) +} diff --git a/agent_loop/src/sse.rs b/agent_loop/src/sse.rs new file mode 100755 index 0000000..7f3e247 --- /dev/null +++ b/agent_loop/src/sse.rs @@ -0,0 +1,237 @@ +use alloc::{string::String, vec::Vec}; +use core::{fmt, str}; +use serde::de::DeserializeOwned; + +use crate::http::{Header, HttpBody}; + +const DEFAULT_MAX_EVENT_SIZE: usize = 64 * 1024; +const READ_BUFFER_SIZE: usize = 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SseEvent { + pub event: Option, + pub data: String, + pub id: Option, + pub retry: Option, +} + +#[derive(Debug)] +pub enum SseJson { + Data { event: Option, data: T }, + Done, +} + +#[derive(Debug)] +pub enum StreamError { + Transport(E), + InvalidUtf8, + InvalidJson(serde_json::Error), + EventTooLarge { limit: usize }, +} + +impl fmt::Display for StreamError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Transport(error) => write!(f, "transport error while reading stream: {error}"), + Self::InvalidUtf8 => f.write_str("SSE stream contained invalid UTF-8"), + Self::InvalidJson(error) => write!(f, "SSE event contained invalid JSON: {error}"), + Self::EventTooLarge { limit } => { + write!(f, "SSE event exceeded the configured {limit}-byte limit") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for StreamError where E: std::error::Error + 'static {} + +#[derive(Debug)] +pub struct ApiStream { + pub status: u16, + pub headers: Vec
, + events: SseStream, +} + +impl ApiStream { + pub(crate) fn new(status: u16, headers: Vec
, body: B) -> Self { + Self { + status, + headers, + events: SseStream::new(body), + } + } + + pub fn events_mut(&mut self) -> &mut SseStream { + &mut self.events + } + + pub fn into_events(self) -> SseStream { + self.events + } +} + +impl ApiStream { + pub fn next_event(&mut self) -> Result, StreamError> { + self.events.next_event() + } + + pub fn next_json(&mut self) -> Result>, StreamError> + where + T: DeserializeOwned, + { + self.events.next_json() + } +} + +#[derive(Debug)] +pub struct SseStream { + body: B, + buffer: Vec, + eof: bool, + max_event_size: usize, +} + +impl SseStream { + pub fn new(body: B) -> Self { + Self { + body, + buffer: Vec::new(), + eof: false, + max_event_size: DEFAULT_MAX_EVENT_SIZE, + } + } + + pub fn with_max_event_size(mut self, max_event_size: usize) -> Self { + self.max_event_size = max_event_size; + self + } + + pub fn into_inner(self) -> B { + self.body + } +} + +impl SseStream { + pub fn next_event(&mut self) -> Result, StreamError> { + loop { + if let Some((index, delimiter_len)) = find_delimiter(&self.buffer) { + if index > self.max_event_size { + return Err(StreamError::EventTooLarge { + limit: self.max_event_size, + }); + } + let event = self.buffer[..index].to_vec(); + self.buffer.drain(..index + delimiter_len); + if let Some(event) = parse_event(&event)? { + return Ok(Some(event)); + } + continue; + } + + if self.eof { + if self.buffer.is_empty() { + return Ok(None); + } + if self.buffer.len() > self.max_event_size { + return Err(StreamError::EventTooLarge { + limit: self.max_event_size, + }); + } + + let event = core::mem::take(&mut self.buffer); + return parse_event(&event); + } + + if self.buffer.len() > self.max_event_size { + return Err(StreamError::EventTooLarge { + limit: self.max_event_size, + }); + } + + let mut chunk = [0u8; READ_BUFFER_SIZE]; + let count = self.body.read(&mut chunk).map_err(StreamError::Transport)?; + if count == 0 { + self.eof = true; + } else { + self.buffer.extend_from_slice(&chunk[..count]); + } + } + } + + pub fn next_json(&mut self) -> Result>, StreamError> + where + T: DeserializeOwned, + { + let Some(event) = self.next_event()? else { + return Ok(None); + }; + + if event.data.trim() == "[DONE]" { + return Ok(Some(SseJson::Done)); + } + + let data = serde_json::from_str(&event.data).map_err(StreamError::InvalidJson)?; + Ok(Some(SseJson::Data { + event: event.event, + data, + })) + } +} + +fn find_delimiter(buffer: &[u8]) -> Option<(usize, usize)> { + let mut index = 0; + while index + 1 < buffer.len() { + if buffer[index] == b'\n' && buffer[index + 1] == b'\n' { + return Some((index, 2)); + } + if index + 3 < buffer.len() && &buffer[index..index + 4] == b"\r\n\r\n" { + return Some((index, 4)); + } + index += 1; + } + None +} + +fn parse_event(bytes: &[u8]) -> Result, StreamError> { + let text = str::from_utf8(bytes).map_err(|_| StreamError::InvalidUtf8)?; + let mut event = None; + let mut data = String::new(); + let mut id = None; + let mut retry = None; + + for raw_line in text.split('\n') { + let line = raw_line.strip_suffix('\r').unwrap_or(raw_line); + if line.is_empty() || line.starts_with(':') { + continue; + } + + let (field, value) = match line.split_once(':') { + Some((field, value)) => (field, value.strip_prefix(' ').unwrap_or(value)), + None => (line, ""), + }; + + match field { + "event" => event = Some(String::from(value)), + "data" => { + if !data.is_empty() { + data.push('\n'); + } + data.push_str(value); + } + "id" => id = Some(String::from(value)), + "retry" => retry = value.parse().ok(), + _ => {} + } + } + + if event.is_none() && data.is_empty() && id.is_none() && retry.is_none() { + return Ok(None); + } + + Ok(Some(SseEvent { + event, + data, + id, + retry, + })) +} diff --git a/agent_loop/src/tls.rs b/agent_loop/src/tls.rs new file mode 100644 index 0000000..74fb142 --- /dev/null +++ b/agent_loop/src/tls.rs @@ -0,0 +1,176 @@ +extern crate embedded_tls; +use alloc::{format, string::String, vec, vec::Vec}; +use embedded_io::{ErrorType, Read, Write}; +use embedded_io_adapters::std::{FromStd, to_std_error}; +use embedded_tls::blocking::*; +use rand_core::{CryptoRng, RngCore}; +use std::net::TcpStream; + +const TLS_RECORD_BUF_SIZE: usize = 16640; + +struct SimpleRng(fastrand::Rng); + +impl RngCore for SimpleRng { + fn next_u32(&mut self) -> u32 { + self.0.u32(..) + } + + fn next_u64(&mut self) -> u64 { + self.0.u64(..) + } + + fn fill_bytes(&mut self, dest: &mut [u8]) { + self.0.fill(dest); + } + + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> { + self.0.fill(dest); + Ok(()) + } +} + +impl CryptoRng for SimpleRng {} + +pub struct TlsSocketStd<'a> { + inner: TlsConnection<'a, FromStd, Aes128GcmSha256>, +} + +impl<'a> ErrorType for TlsSocketStd<'a> { + type Error = std::io::Error; +} + +impl<'a> Read for TlsSocketStd<'a> { + fn read(&mut self, buf: &mut [u8]) -> Result { + self.inner.read(buf).map_err(to_std_error) + } +} + +impl<'a> Write for TlsSocketStd<'a> { + fn write(&mut self, buf: &[u8]) -> Result { + self.inner.write(buf).map_err(to_std_error) + } + + fn flush(&mut self) -> Result<(), Self::Error> { + self.inner.flush().map_err(to_std_error) + } +} + +pub enum AgentSocket<'a> { + Plain(FromStd), + Tls(TlsSocketStd<'a>), +} + +impl<'a> ErrorType for AgentSocket<'a> { + type Error = std::io::Error; +} + +impl<'a> Read for AgentSocket<'a> { + fn read(&mut self, buf: &mut [u8]) -> Result { + match self { + Self::Plain(s) => s.read(buf), + Self::Tls(s) => s.read(buf), + } + } +} + +impl<'a> Write for AgentSocket<'a> { + fn write(&mut self, buf: &[u8]) -> Result { + match self { + Self::Plain(s) => s.write(buf), + Self::Tls(s) => s.write(buf), + } + } + + fn flush(&mut self) -> Result<(), Self::Error> { + match self { + Self::Plain(s) => s.flush(), + Self::Tls(s) => s.flush(), + } + } +} + +pub struct EmbeddedTlsTransport { + read_buf: Vec, + write_buf: Vec, + rng: SimpleRng, + sni: Option, +} + +impl EmbeddedTlsTransport { + pub fn new() -> Self { + Self { + read_buf: vec![0u8; TLS_RECORD_BUF_SIZE], + write_buf: vec![0u8; TLS_RECORD_BUF_SIZE], + rng: SimpleRng(fastrand::Rng::with_seed(0xDEAD_BEEF_CAFE_BABE)), + sni: None, + } + } + + pub fn with_sni(mut self, name: impl Into) -> Self { + self.sni = Some(name.into()); + self + } +} + +impl Default for EmbeddedTlsTransport { + fn default() -> Self { + Self::new() + } +} + +impl crate::http::SocketTransport for EmbeddedTlsTransport { + type Error = std::io::Error; + type Socket<'a> = AgentSocket<'a>; + + fn connect<'a>( + &'a mut self, + host: &str, + port: u16, + scheme: crate::http::Scheme, + ) -> Result, Self::Error> { + println!("[http] connecting to {}:{}...", host, port); + let stream = TcpStream::connect(("124.72.129.70", port))?; + stream.set_nodelay(true)?; + println!("[http] TCP connected"); + + match scheme { + crate::http::Scheme::Http => Ok(AgentSocket::Plain(FromStd::new(stream))), + crate::http::Scheme::Https => { + let server_name = self.sni.as_deref().unwrap_or(host); + let write_buf_ptr = self.write_buf.as_ptr(); + let read_buf_ptr = self.read_buf.as_ptr(); + let mut tls: TlsConnection, Aes128GcmSha256> = TlsConnection::new( + FromStd::new(stream), + &mut self.read_buf[..], + &mut self.write_buf[..], + ); + + let config = TlsConfig::new() + .with_server_name(server_name) + .enable_rsa_signatures(); + println!("[http] TLS handshake..."); + let result = tls.open::(TlsContext::new(&config, &mut self.rng)); + if let Err(ref e) = result { + println!("[http] TLS handshake failed: {:?}", e); + let wbuf = unsafe { core::slice::from_raw_parts(write_buf_ptr, 200) }; + let rbuf = unsafe { core::slice::from_raw_parts(read_buf_ptr, 64) }; + let w_hex: String = wbuf.iter().map(|b| format!("{:02x}", b)).collect(); + let r_hex: String = rbuf.iter().map(|b| format!("{:02x}", b)).collect(); + println!("[tls] ClientHello (write_buf first 200 bytes):"); + println!("[tls] {}", w_hex); + println!("[tls] server response (read_buf first 64 bytes):"); + println!("[tls] {}", r_hex); + } + result.map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + format!("TLS handshake failed: {e:?}"), + ) + })?; + println!("[http] TLS established"); + + Ok(AgentSocket::Tls(TlsSocketStd { inner: tls })) + } + } + } +} diff --git a/agent_loop/src/wifi.rs b/agent_loop/src/wifi.rs new file mode 100644 index 0000000..a616ce4 --- /dev/null +++ b/agent_loop/src/wifi.rs @@ -0,0 +1,250 @@ +use librs::syscall::Syscall; + +const SCAN_POLL_ATTEMPTS: usize = 25; +const SCAN_POLL_INTERVAL_MS: u32 = 200; +const WIFI_CONNECT_WAIT_MS: u32 = 15000; +const WIFI_SSID: &[u8] = blueos_kconfig::CONFIG_WLAN_SSID; +const WIFI_PASSWORD: &[u8] = blueos_kconfig::CONFIG_WLAN_PASSWORD; + +fn security_name(sec: u8) -> &'static str { + match sec { + 0 => "Open", + 1 => "WEP", + 2 => "WPA", + 3 => "WPA2", + 4 => "WPA3", + _ => "Unknown", + } +} + +fn wlan0_name() -> [libc::c_char; 16] { + let mut name = [0 as libc::c_char; 16]; + let bytes = b"wlan0\0"; + let mut i = 0; + while i < bytes.len() && i < name.len() { + name[i] = bytes[i] as libc::c_char; + i += 1; + } + name +} + +pub fn connect_wifi() -> std::io::Result<()> { + let _d = librs::time::msleep(1000); + + println!("Wifi example"); + let fd = librs::net::socket::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0); + if fd < 0 { + eprintln!( + "Failed to create socket: {}", + std::io::Error::last_os_error() + ); + return Err(std::io::Error::last_os_error()); + } + + println!("Scanning for WiFi networks..."); + + // SIOCSIWSCAN: trigger scan on wlan0 with struct iw_scan_req + let scan_req = libc::iw_scan_req { + scan_type: libc::IW_SCAN_TYPE_ACTIVE as u8, + essid_len: 0, + num_channels: 0, + flags: 0, + bssid: unsafe { core::mem::zeroed() }, + essid: [0u8; libc::IW_ESSID_MAX_SIZE], + min_channel_time: 0, + max_channel_time: 0, + channel_list: [libc::iw_freq { + m: 0, + e: 0, + i: 0, + flags: 0, + }; libc::IW_MAX_FREQUENCIES], + }; + let mut iwreq = libc::iwreq { + ifr_ifrn: libc::__c_anonymous_iwreq { + ifrn_name: wlan0_name(), + }, + u: libc::iwreq_data { + essid: libc::iw_point { + pointer: &scan_req as *const libc::iw_scan_req as *mut libc::c_void, + length: core::mem::size_of::() as u16, + flags: 0, + }, + }, + }; + let ret = unsafe { + librs::syscall::sys::Sys::ioctl( + fd, + libc::SIOCSIWSCAN, + &mut iwreq as *mut _ as *mut libc::c_void, + ) + }; + if let Err(librs::errno::Errno(errno)) = ret { + eprintln!("SIOCSIWSCAN failed: errno={}", errno); + return Err(std::io::Error::from_raw_os_error(errno)); + } + println!("Scan triggered."); + + // SIOCGIWSCAN: retrieve results into a 4 KiB buffer + let mut buf = vec![0u8; 4096]; + let data = libc::iw_point { + pointer: buf.as_mut_ptr() as *mut libc::c_void, + length: buf.len() as u16, + flags: 0, + }; + let mut iwreq = libc::iwreq { + ifr_ifrn: libc::__c_anonymous_iwreq { + ifrn_name: wlan0_name(), + }, + u: libc::iwreq_data { data }, + }; + let mut total = None; + for attempt in 0..SCAN_POLL_ATTEMPTS { + let ret = unsafe { + librs::syscall::sys::Sys::ioctl( + fd, + libc::SIOCGIWSCAN, + &mut iwreq as *mut _ as *mut libc::c_void, + ) + }; + match ret { + Ok(n) if n >= 0 => { + total = Some(n as usize); + break; + } + Err(librs::errno::Errno(errno)) if attempt + 1 < SCAN_POLL_ATTEMPTS => { + if errno != libc::EAGAIN { + eprintln!("SIOCGIWSCAN failed: errno={}", errno); + return Err(std::io::Error::from_raw_os_error(errno)); + } + let _ = librs::time::msleep(SCAN_POLL_INTERVAL_MS); + } + Err(librs::errno::Errno(errno)) => { + eprintln!( + "SIOCGIWSCAN failed or scan results are not ready: errno={}", + errno + ); + return Err(std::io::Error::from_raw_os_error(errno)); + } + _ => { + eprintln!("SIOCGIWSCAN returned an invalid success value"); + return Err(std::io::Error::from_raw_os_error(libc::EINVAL)); + } + } + } + let total = total.unwrap_or(0); + + // Decode: [u32 count][entry]* + if total < 4 { + println!("No scan results ({} bytes returned)", total); + return Ok(()); + } + let count = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); + println!("Found {} networks:\n", count); + + let mut off = 4usize; + for i in 0..count { + if off + 4 > total { + break; + } + let ssid_len = u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]); + off += 4; + + if off + ssid_len as usize > total { + break; + } + let ssid = + core::str::from_utf8(&buf[off..off + ssid_len as usize]).unwrap_or(""); + off += ssid_len as usize; + + if off + 6 > total { + break; + } + let bssid = &buf[off..off + 6]; + off += 6; + + if off + 1 > total { + break; + } + let signal = buf[off] as i8; + off += 1; + + if off + 2 > total { + break; + } + let channel = u16::from_le_bytes([buf[off], buf[off + 1]]); + off += 2; + + if off + 1 > total { + break; + } + let security = buf[off]; + off += 1; + + println!(" {}. SSID: {}", i + 1, ssid); + println!( + " BSSID: {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5] + ); + println!(" Signal: {} dBm", signal); + println!(" Channel: {}", channel); + println!(" Security: {}", security_name(security)); + println!(); + } + + println!("Connecting to WiFi SSID: test_esp (WPA2)..."); + + // SIOCSIWENCODE — cache passphrase for WPA2 network + let mut iwreq = libc::iwreq { + ifr_ifrn: libc::__c_anonymous_iwreq { + ifrn_name: wlan0_name(), + }, + u: libc::iwreq_data { + encoding: libc::iw_point { + pointer: WIFI_PASSWORD.as_ptr() as *mut libc::c_void, + length: WIFI_PASSWORD.len() as u16, + flags: 0, + }, + }, + }; + let ret = unsafe { + librs::syscall::sys::Sys::ioctl( + fd, + libc::SIOCSIWENCODE, + &mut iwreq as *mut _ as *mut libc::c_void, + ) + }; + if let Err(librs::errno::Errno(errno)) = ret { + eprintln!("SIOCSIWENCODE failed: errno={}", errno); + } + + // SIOCSIWESSID — trigger connect + let mut iwreq = libc::iwreq { + ifr_ifrn: libc::__c_anonymous_iwreq { + ifrn_name: wlan0_name(), + }, + u: libc::iwreq_data { + essid: libc::iw_point { + pointer: WIFI_SSID.as_ptr() as *mut libc::c_void, + // The length should not include the null terminator, so we subtract 1. + length: (WIFI_SSID.len() - 1) as u16, + flags: 1, + }, + }, + }; + let ret = unsafe { + librs::syscall::sys::Sys::ioctl( + fd, + libc::SIOCSIWESSID, + &mut iwreq as *mut _ as *mut libc::c_void, + ) + }; + if let Err(librs::errno::Errno(errno)) = ret { + eprintln!("SIOCSIWESSID failed: errno={}", errno); + return Err(std::io::Error::from_raw_os_error(errno)); + } + + println!("WiFi connect triggered, waiting for station connection..."); + let _ = librs::time::msleep(WIFI_CONNECT_WAIT_MS); + Ok(()) +} diff --git a/wifi/BUILD.gn b/wifi/BUILD.gn new file mode 100644 index 0000000..b46a1db --- /dev/null +++ b/wifi/BUILD.gn @@ -0,0 +1,70 @@ +# Copyright (c) 2025 vivo Mobile Communication Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import("//build/boards/${board}.gni") +import("//build/templates/reload_autoconf_and_build.gni") +import("//build/templates/rust.gni") +import("//build/toolchain/blueos.gni") + +reload_autoconf_and_build("wifi_example") { + app_conf = [ "app.conf" ] + crate_name = "wifi_example" + crate_type = "bin" + + sources = [ "src/main.rs" ] + deps = [ + "//kernel/adapter/esp_radio_sys", + "//kernel/kconfig:blueos_kconfig", + "//kernel/rsrt:rsrt_std", + "//libc", + "//librs", + "//external/vendor/embedded-tls-0.16.3:embedded_tls", + "//external/vendor/embedded-io-adapters-0.6.2:embedded_io_adapters", + "//external/vendor/fastrand-2.3.0:fastrand", + "//external/vendor/rand_core-0.6.4:rand_core", + ] + linker_script = default_linker_script + configs = [ "//build/boards/${board}:kernel_config" ] + + wifi_ldflags = [ + "-L" + rebase_path("${root_build_dir}/obj/external/vendor/esp-phy-0.2.0"), + "-L" + + rebase_path("${root_build_dir}/obj/external/vendor/esp-radio-0.18.0"), + "-L" + rebase_path("//external/vendor/esp-wifi-sys-esp32c3-0.2.0/libs"), + "-L" + rebase_path("//external/vendor/esp-rom-sys-0.1.4/ld/esp32c3"), + "-L" + rebase_path("//external/vendor/esp-radio-0.18.0"), + "-L" + rebase_path("//external/vendor/esp-phy-0.2.0"), + "-L" + rebase_path("//external/vendor/embedded-tls-0.16.3"), + "-lesp-phy", + "-lesp_rom_sys", + "-lbtbb", + "-lbtdm_app", + "-lcoexist", + "-lespnow", + "-lmesh", + "-lnet80211", + "-lcore", + "-lsmartconfig", + "-lwapi", + "-lwpa_supplicant", + "-lregulatory", + "-lphy", + "-lpp", + # "-lembedded_tls", + ] + rustflags = [] + foreach(item, wifi_ldflags) { + rustflags += [ "-Clink-arg=${item}" ] + } +} diff --git a/wifi/app.conf b/wifi/app.conf new file mode 100644 index 0000000..0ed104e --- /dev/null +++ b/wifi/app.conf @@ -0,0 +1,9 @@ +CONFIG_ENABLE_NET=y +CONFIG_ENABLE_VFS=y +CONFIG_KERNEL_ASYNC=y +CONFIG_NET_ROUTER_IP="10.174.65.17" +CONFIG_NET_STATIC_IP="10.174.65.27" +CONFIG_ENABLE_WLAN=y +# Change the following two lines to your Wi-Fi SSID and password. +CONFIG_WLAN_SSID="test_esp" +CONFIG_WLAN_PASSWORD="88888888" diff --git a/wifi/src/main.rs b/wifi/src/main.rs new file mode 100644 index 0000000..97031be --- /dev/null +++ b/wifi/src/main.rs @@ -0,0 +1,476 @@ +// Copyright (c) 2025 vivo Mobile Communication Co., Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +extern crate esp_radio_sys; +use librs::syscall::Syscall; +use std::{ + io::{Read, Write}, + net::{Ipv4Addr, SocketAddrV4, TcpStream}, +}; + +const SCAN_POLL_ATTEMPTS: usize = 25; +const SCAN_POLL_INTERVAL_MS: u32 = 200; +const WIFI_CONNECT_WAIT_MS: u32 = 15000; +const WIFI_SSID: &[u8] = blueos_kconfig::CONFIG_WLAN_SSID; +const WIFI_PASSWORD: &[u8] = blueos_kconfig::CONFIG_WLAN_PASSWORD; +const PHONE_TCP_SERVER_PORT: u16 = 34228; +const TCP_TEST_PAYLOAD: &[u8] = b"blueos-wifi-tcp-link-test"; +const TCP_RECV_EXPECTED_MESSAGES: usize = 2; +const TCP_RECV_BUF_SIZE: usize = 512; +const HTTPS_SERVER_NAME: &str = "api.openai.com"; +const HTTPS_SERVER_PORT: u16 = 443; +const HTTPS_REQUEST_PATH: &str = "/v1/models"; +const TLS_RECORD_BUF_SIZE: usize = 16640; +const HTTPS_RECV_BUF_SIZE: usize = 4096; + +extern crate embedded_io_adapters; +extern crate fastrand; +extern crate librs; +extern crate rsrt; + +fn security_name(sec: u8) -> &'static str { + match sec { + 0 => "Open", + 1 => "WEP", + 2 => "WPA", + 3 => "WPA2", + 4 => "WPA3", + _ => "Unknown", + } +} + +fn wlan0_name() -> [libc::c_char; 16] { + let mut name = [0 as libc::c_char; 16]; + let bytes = b"wlan0\0"; + let mut i = 0; + while i < bytes.len() && i < name.len() { + name[i] = bytes[i] as libc::c_char; + i += 1; + } + name +} + +fn parse_router_ip() -> std::io::Result { + let ip_bytes = blueos_kconfig::CONFIG_NET_ROUTER_IP; + let len = ip_bytes + .iter() + .position(|&byte| byte == b'\0') + .unwrap_or(ip_bytes.len()); + let ip = core::str::from_utf8(&ip_bytes[..len]) + .map_err(|_| std::io::Error::from_raw_os_error(libc::EINVAL))?; + let mut parts = ip.split('.'); + + let a = parts + .next() + .and_then(|part| part.parse::().ok()) + .ok_or_else(|| std::io::Error::from_raw_os_error(libc::EINVAL))?; + let b = parts + .next() + .and_then(|part| part.parse::().ok()) + .ok_or_else(|| std::io::Error::from_raw_os_error(libc::EINVAL))?; + let c = parts + .next() + .and_then(|part| part.parse::().ok()) + .ok_or_else(|| std::io::Error::from_raw_os_error(libc::EINVAL))?; + let d = parts + .next() + .and_then(|part| part.parse::().ok()) + .ok_or_else(|| std::io::Error::from_raw_os_error(libc::EINVAL))?; + + if parts.next().is_some() { + return Err(std::io::Error::from_raw_os_error(libc::EINVAL)); + } + + Ok(Ipv4Addr::new(a, b, c, d)) +} + +fn run_phone_tcp_check() -> std::io::Result<()> { + let phone_tcp_server = SocketAddrV4::new(parse_router_ip()?, PHONE_TCP_SERVER_PORT); + println!("TCP link check: connecting to {}", phone_tcp_server); + let mut stream = TcpStream::connect(phone_tcp_server)?; + stream.write_all(TCP_TEST_PAYLOAD)?; + println!( + "WiFi phone TCP TX OK: sent {} bytes to {}: {:?}", + TCP_TEST_PAYLOAD.len(), + phone_tcp_server, + TCP_TEST_PAYLOAD + ); + + println!( + "TCP link check: waiting for response from {}", + phone_tcp_server + ); + let mut rx_buf = [0u8; TCP_RECV_BUF_SIZE]; + for message_index in 0..TCP_RECV_EXPECTED_MESSAGES { + let size = stream.read(&mut rx_buf)?; + if size == 0 { + println!( + "WiFi phone TCP RX closed after {} message(s)", + message_index + ); + return Ok(()); + } + + println!( + "WiFi phone TCP RX OK: message {}/{} received {} bytes from {}: {:?}", + message_index + 1, + TCP_RECV_EXPECTED_MESSAGES, + size, + phone_tcp_server, + &rx_buf[..size] + ); + if let Ok(text) = core::str::from_utf8(&rx_buf[..size]) { + println!("WiFi phone TCP RX text: {}", text); + } + } + + println!( + "WiFi phone TCP RX check done: received {} message(s)", + TCP_RECV_EXPECTED_MESSAGES + ); + Ok(()) +} + +extern crate embedded_tls; +use embedded_io_adapters::std::FromStd; +use embedded_tls::blocking::*; +use rand_core::{CryptoRng, RngCore}; + +struct SimpleRng(fastrand::Rng); + +impl RngCore for SimpleRng { + fn next_u32(&mut self) -> u32 { + self.0.u32(..) + } + + fn next_u64(&mut self) -> u64 { + self.0.u64(..) + } + + fn fill_bytes(&mut self, dest: &mut [u8]) { + self.0.fill(dest); + } + + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> { + self.0.fill(dest); + Ok(()) + } +} + +impl CryptoRng for SimpleRng {} + +fn run_phone_https_check() -> std::io::Result<()> { + let addr = SocketAddrV4::new(Ipv4Addr::new(172,66,0,243), HTTPS_SERVER_PORT); + println!( + "HTTPS check: connecting to {} (SNI: {})", + addr, HTTPS_SERVER_NAME + ); + + let stream = TcpStream::connect(addr)?; + println!("HTTPS TCP connected"); + + let mut read_record_buffer = vec![0u8; TLS_RECORD_BUF_SIZE]; + let mut write_record_buffer = vec![0u8; TLS_RECORD_BUF_SIZE]; + + println!("buffer allocated"); + + let config = TlsConfig::new().with_server_name(HTTPS_SERVER_NAME); + //let config = TlsConfig::new(); + let mut tls: TlsConnection, Aes128GcmSha256> = TlsConnection::new( + FromStd::new(stream), + &mut read_record_buffer[..], + &mut write_record_buffer[..], + ); + + println!("tlsconfig finished"); + + let mut rng = SimpleRng(fastrand::Rng::with_seed(0xDEAD_BEEF_CAFE_BABE)); + println!("rng finished"); + tls.open::(TlsContext::new(&config, &mut rng)) + .map_err(|e| { + println!("TLS handshake failed: {:?}", e); + std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "TLS handshake failed") + })?; + + println!("TLS handshake OK, sending HTTPS request"); + + let request = format!( + "GET {} HTTP/1.0\r\nHost: {}\r\nConnection: close\r\n\r\n", + HTTPS_REQUEST_PATH, HTTPS_SERVER_NAME + ); + tls.write(request.as_bytes()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("TLS write: {:?}", e)))?; + tls.flush() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("TLS flush: {:?}", e)))?; + + println!("HTTPS request sent, reading response..."); + + let mut rx_buf = vec![0u8; HTTPS_RECV_BUF_SIZE]; + let mut total_read = 0usize; + loop { + match tls.read(&mut rx_buf[total_read..]) { + Ok(0) => break, + Ok(n) => { + total_read += n; + if total_read >= rx_buf.len() { + break; + } + } + Err(e) => { + println!("TLS read error: {:?}", e); + break; + } + } + } + + if total_read > 0 { + println!( + "HTTPS response ({} bytes):\n{}", + total_read, + core::str::from_utf8(&rx_buf[..total_read]).unwrap_or("") + ); + } else { + println!("HTTPS response: no data received"); + } + + match tls.close() { + Ok(_sock) => println!("TLS connection closed"), + Err((_, e)) => println!("TLS close error: {:?}", e), + } + + Ok(()) +} + +fn main() -> std::io::Result<()> { + let _d = librs::time::msleep(1000); + + println!("Wifi example"); + let fd = librs::net::socket::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0); + if fd < 0 { + eprintln!( + "Failed to create socket: {}", + std::io::Error::last_os_error() + ); + return Err(std::io::Error::last_os_error()); + } + + println!("Scanning for WiFi networks..."); + + // SIOCSIWSCAN: trigger scan on wlan0 with struct iw_scan_req + let scan_req = libc::iw_scan_req { + scan_type: libc::IW_SCAN_TYPE_ACTIVE as u8, + essid_len: 0, + num_channels: 0, + flags: 0, + bssid: unsafe { core::mem::zeroed() }, + essid: [0u8; libc::IW_ESSID_MAX_SIZE], + min_channel_time: 0, + max_channel_time: 0, + channel_list: [libc::iw_freq { + m: 0, + e: 0, + i: 0, + flags: 0, + }; libc::IW_MAX_FREQUENCIES], + }; + let mut iwreq = libc::iwreq { + ifr_ifrn: libc::__c_anonymous_iwreq { + ifrn_name: wlan0_name(), + }, + u: libc::iwreq_data { + essid: libc::iw_point { + pointer: &scan_req as *const libc::iw_scan_req as *mut libc::c_void, + length: core::mem::size_of::() as u16, + flags: 0, + }, + }, + }; + let ret = unsafe { + librs::syscall::sys::Sys::ioctl( + fd, + libc::SIOCSIWSCAN, + &mut iwreq as *mut _ as *mut libc::c_void, + ) + }; + if let Err(librs::errno::Errno(errno)) = ret { + eprintln!("SIOCSIWSCAN failed: errno={}", errno); + return Err(std::io::Error::from_raw_os_error(errno)); + } + println!("Scan triggered."); + + // SIOCGIWSCAN: retrieve results into a 4 KiB buffer + let mut buf = vec![0u8; 4096]; + let data = libc::iw_point { + pointer: buf.as_mut_ptr() as *mut libc::c_void, + length: buf.len() as u16, + flags: 0, + }; + let mut iwreq = libc::iwreq { + ifr_ifrn: libc::__c_anonymous_iwreq { + ifrn_name: wlan0_name(), + }, + u: libc::iwreq_data { data }, + }; + let mut total = None; + for attempt in 0..SCAN_POLL_ATTEMPTS { + let ret = unsafe { + librs::syscall::sys::Sys::ioctl( + fd, + libc::SIOCGIWSCAN, + &mut iwreq as *mut _ as *mut libc::c_void, + ) + }; + match ret { + Ok(n) if n >= 0 => { + total = Some(n as usize); + break; + } + Err(librs::errno::Errno(errno)) if attempt + 1 < SCAN_POLL_ATTEMPTS => { + if errno != libc::EAGAIN { + eprintln!("SIOCGIWSCAN failed: errno={}", errno); + return Err(std::io::Error::from_raw_os_error(errno)); + } + let _ = librs::time::msleep(SCAN_POLL_INTERVAL_MS); + } + Err(librs::errno::Errno(errno)) => { + eprintln!( + "SIOCGIWSCAN failed or scan results are not ready: errno={}", + errno + ); + return Err(std::io::Error::from_raw_os_error(errno)); + } + _ => { + eprintln!("SIOCGIWSCAN returned an invalid success value"); + return Err(std::io::Error::from_raw_os_error(libc::EINVAL)); + } + } + } + let total = total.unwrap_or(0); + + // Decode: [u32 count][entry]* + if total < 4 { + println!("No scan results ({} bytes returned)", total); + return Ok(()); + } + let count = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); + println!("Found {} networks:\n", count); + + let mut off = 4usize; + for i in 0..count { + if off + 4 > total { + break; + } + let ssid_len = u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]); + off += 4; + + if off + ssid_len as usize > total { + break; + } + let ssid = + core::str::from_utf8(&buf[off..off + ssid_len as usize]).unwrap_or(""); + off += ssid_len as usize; + + if off + 6 > total { + break; + } + let bssid = &buf[off..off + 6]; + off += 6; + + if off + 1 > total { + break; + } + let signal = buf[off] as i8; + off += 1; + + if off + 2 > total { + break; + } + let channel = u16::from_le_bytes([buf[off], buf[off + 1]]); + off += 2; + + if off + 1 > total { + break; + } + let security = buf[off]; + off += 1; + + println!(" {}. SSID: {}", i + 1, ssid); + println!( + " BSSID: {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5] + ); + println!(" Signal: {} dBm", signal); + println!(" Channel: {}", channel); + println!(" Security: {}", security_name(security)); + println!(); + } + + println!("Connecting to WiFi SSID: test_esp (WPA2)..."); + + // SIOCSIWENCODE — cache passphrase for WPA2 network + let mut iwreq = libc::iwreq { + ifr_ifrn: libc::__c_anonymous_iwreq { + ifrn_name: wlan0_name(), + }, + u: libc::iwreq_data { + encoding: libc::iw_point { + pointer: WIFI_PASSWORD.as_ptr() as *mut libc::c_void, + length: WIFI_PASSWORD.len() as u16, + flags: 0, + }, + }, + }; + let ret = unsafe { + librs::syscall::sys::Sys::ioctl( + fd, + libc::SIOCSIWENCODE, + &mut iwreq as *mut _ as *mut libc::c_void, + ) + }; + if let Err(librs::errno::Errno(errno)) = ret { + eprintln!("SIOCSIWENCODE failed: errno={}", errno); + } + + // SIOCSIWESSID — trigger connect + let mut iwreq = libc::iwreq { + ifr_ifrn: libc::__c_anonymous_iwreq { + ifrn_name: wlan0_name(), + }, + u: libc::iwreq_data { + essid: libc::iw_point { + pointer: WIFI_SSID.as_ptr() as *mut libc::c_void, + // The length should not include the null terminator, so we subtract 1. + length: (WIFI_SSID.len() - 1) as u16, + flags: 1, + }, + }, + }; + let ret = unsafe { + librs::syscall::sys::Sys::ioctl( + fd, + libc::SIOCSIWESSID, + &mut iwreq as *mut _ as *mut libc::c_void, + ) + }; + if let Err(librs::errno::Errno(errno)) = ret { + eprintln!("SIOCSIWESSID failed: errno={}", errno); + return Err(std::io::Error::from_raw_os_error(errno)); + } + + println!("WiFi connect triggered, waiting for station connection..."); + let _ = librs::time::msleep(WIFI_CONNECT_WAIT_MS); + run_phone_tcp_check(); + run_phone_https_check(); + Ok(()) +}