Guest WebAssembly scripting examples for aa-proxy-rs.
This repository contains a Rust guest component that implements the aa:packet/packet-hook WIT world used by aa-proxy-rs.
The generated .wasm component can be copied into the aa-proxy WASM hook directory and loaded by the host at runtime.
Example hook directory on device:
/data/wasm-hooks/Only compiled .wasm component files should be placed there. Do not copy .rs, .wit, or source files into /data/wasm-hooks/.
A guest WASM component can:
- inspect Android Auto proxy packets
- forward or drop packets
- replace the currently processed packet
- send additional packets
- write logs through the host
- publish WebSocket events through the host
- receive script-level WebSocket events
- call selected aa-proxy REST endpoints synchronously
- call selected aa-proxy REST endpoints asynchronously
- keep guest-side state between calls
- run lifecycle hooks with
on-createandon-destroy - expose custom configuration fields in the aa-proxy config UI
- read custom configuration values through
host::get_config - receive live config updates through
on-config-changed
The host keeps one live WASM instance per loaded script.
That means guest-side state such as thread_local!, Cell, RefCell, static counters, cached config, and other in-memory values survive between calls.
Typical lifecycle:
script file loaded
first script use or config discovery
-> on_create()
-> custom_configs()
modify_packet / ws_script_handler calls reuse the same guest instance
config changed from UI
-> on_config_changed(name, value)
script file changed, removed, reloaded, or host shuts down
-> on_destroy()
Reloading the script resets guest memory because the host creates a new component instance.
Scripts can expose their own config section by implementing:
fn custom_configs() -> Vec<CustomConfigSection>The host namespaces each config key automatically using the script file name.
For example, if the script file is:
/data/wasm-hooks/test_hook.wasm
and the guest returns a config named:
log_every
the UI/host key becomes:
wasm.test_hook.log_every
Inside the guest, always read the local key only:
host::get_config("log_every")Do not read the full namespaced key from the guest.
Custom config values are persisted by the host at:
/data/aa-proxy-rs/wasm-config.toml
The default_value returned by custom_configs() is used when no saved value exists yet.
fn custom_configs() -> Vec<CustomConfigSection> {
vec![CustomConfigSection {
title: "WASM Config Test".to_string(),
values: vec![
CustomConfigEntry {
name: "enabled".to_string(),
typ: "bool".to_string(),
description: "Enable packet logging from this WASM script".to_string(),
default_value: "true".to_string(),
values: None,
},
CustomConfigEntry {
name: "log_every".to_string(),
typ: "number".to_string(),
description: "Log every N packets. Use 1 to log every packet.".to_string(),
default_value: "20".to_string(),
values: None,
},
CustomConfigEntry {
name: "label".to_string(),
typ: "string".to_string(),
description: "Label printed in WASM info logs".to_string(),
default_value: "wasm config test".to_string(),
values: None,
},
],
}]
}Supported typ values should match the aa-proxy config UI types, for example:
bool
number
string
select
For select-like configs, set values: Some(vec![...]).
Use host::get_config(name).
The return value is Option<String>.
Example helpers:
fn read_bool(name: &str, default: bool) -> bool {
host::get_config(name)
.and_then(|v| v.parse::<bool>().ok())
.unwrap_or(default)
}
fn read_u64(name: &str, default: u64) -> u64 {
host::get_config(name)
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(default)
}
fn read_string(name: &str, default: &str) -> String {
host::get_config(name).unwrap_or_else(|| default.to_string())
}Recommended pattern:
- read and cache config in
on_create() - update the cached config in
on_config_changed() - use cached values inside high-frequency
modify_packet()calls
Avoid parsing config repeatedly inside modify_packet() for every packet.
The host exposes WASM script limits in the normal aa-proxy config UI before the dynamic script config sections.
Typical fields:
wasm_script_memory_limit_mb
wasm_script_instance_limit
wasm_script_memory_count_limit
wasm_script_table_limit
wasm_script_table_elements_limit
wasm_script_packet_epoch_deadline
wasm_script_lifecycle_epoch_deadline
These are host-side safety limits and are saved in the normal aa-proxy config, not in /data/aa-proxy-rs/wasm-config.toml.
modify_packet() should stay fast. If packet hooks start timing out, increase the packet epoch deadline carefully or move heavier work to ws_script_handler() / async REST calls.
The guest component uses the aa:packet/packet-hook world.
package aa:packet;
interface types {
enum proxy-type {
head-unit,
mobile-device,
}
record modify-context {
sensor-channel: option<u8>,
nav-channel: option<u8>,
audio-channels: list<u8>,
}
record packet {
proxy-type: proxy-type,
channel: u8,
packet-flags: u8,
final-length: option<u32>,
message-id: u16,
payload: list<u8>,
}
record config-view {
audio-max-unacked: u32,
remove-tap-restriction: bool,
video-in-motion: bool,
developer-mode: bool,
ev: bool,
waze-lht-workaround: bool,
}
record custom-config-entry {
name: string,
typ: string,
description: string,
default-value: string,
values: option<list<string>>,
}
record custom-config-section {
title: string,
values: list<custom-config-entry>,
}
enum decision {
forward,
drop,
}
}
interface host {
use types.{packet};
replace-current: func(pkt: packet);
send: func(pkt: packet);
info: func(msg: string);
error: func(msg: string);
send-ws-event: func(topic: string, payload: string) -> bool;
rest-call: func(method: string, path: string, body: string) -> string;
rest-call-async: func(method: string, path: string, body: string) -> string;
rest-result-topic: func() -> string;
get-config: func(name: string) -> option<string>;
}
world packet-hook {
use types.{
modify-context,
packet,
config-view,
custom-config-section,
decision,
};
import host;
export on-create: func();
export on-destroy: func();
export custom-configs: func() -> list<custom-config-section>;
export on-config-changed: func(name: string, value: string);
export modify-packet: func(ctx: modify-context, pkt: packet, cfg: config-view) -> decision;
export ws-script-handler: func(topic: string, payload: string) -> string;
}Writes an info log through the aa-proxy host.
host::info("hello from wasm script");Writes an error log through the aa-proxy host.
host::error("something went wrong");Returns the current saved value for a custom script config key.
Use the local key name, not the full wasm.<script>.<key> name.
let enabled = host::get_config("enabled")
.and_then(|v| v.parse::<bool>().ok())
.unwrap_or(true);Replaces the currently processed packet.
let mut modified = pkt.clone();
modified.payload = vec![0x12, 0x34, 0xAA, 0xBB];
host::replace_current(&modified);
Decision::ForwardSends an additional packet.
let mut extra = pkt.clone();
extra.payload = vec![0x33, 0x33, 0x00, 0x01];
host::send(&extra);
Decision::ForwardPublishes a WebSocket event through the aa-proxy host.
let ok = host::send_ws_event(
"script.event",
r#"{"message":"hello from wasm"}"#,
);
if !ok {
host::error("failed to send websocket event");
}Calls an allowed aa-proxy REST endpoint synchronously.
Example:
let response = host::rest_call("GET", "/speed", "");
host::info(&format!("speed response: {response}"));Use synchronous REST calls only for low-frequency actions. Avoid calling REST endpoints for every packet inside modify_packet().
Starts an allowed aa-proxy REST call in the host and immediately returns a request id.
The result is published later as a WebSocket event.
let request_id = host::rest_call_async(
"POST",
"/battery",
r#"{"percentage":80}"#,
);
let result_topic = host::rest_result_topic();
host::info(&format!(
"started async REST call request_id={request_id} result_topic={result_topic}"
));Returns the WebSocket topic used by rest_call_async() results.
let topic = host::rest_result_topic();
host::info(&format!("async REST results will be published on: {topic}"));Called when the host creates the live guest instance.
Use it to initialize guest-side state and read initial custom config values.
fn on_create() {
reload_config();
host::info("script created");
}Called before the live guest instance is destroyed during reload/removal/shutdown.
Use it to log final state or cleanup guest-side resources.
fn on_destroy() {
host::info("script destroyed");
}Returns the custom config sections that should be appended to the aa-proxy config UI.
The host persists values and provides them back through host::get_config().
Called when one custom config value for this script changes.
name is the local key name, for example log_every, not wasm.test_hook.log_every.
fn on_config_changed(name: String, value: String) {
reload_config();
host::info(&format!("config changed: {name}={value}"));
}Called by the host for packet-processing hooks.
Return:
Decision::Forwardto forward the packetDecision::Dropto drop the packet
Called by the host for script-level WebSocket events.
If the returned string is empty, the host may treat the event as not handled.
This example exposes three custom config values:
enabled = true
log_every = 20
label = wasm config test
Set log_every to 1 from the aa-proxy config UI to log every packet.
#[allow(warnings)]
mod bindings;
use bindings::aa::packet::host;
use bindings::aa::packet::types::{
ConfigView,
CustomConfigEntry,
CustomConfigSection,
Decision,
ModifyContext,
Packet,
ProxyType,
};
use bindings::Guest;
use std::cell::RefCell;
struct Component;
#[derive(Clone)]
struct RuntimeConfig {
enabled: bool,
log_every: u64,
label: String,
packet_count: u64,
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
enabled: true,
log_every: 20,
label: "wasm config test".to_string(),
packet_count: 0,
}
}
}
thread_local! {
static CONFIG: RefCell<RuntimeConfig> = RefCell::new(RuntimeConfig::default());
}
fn read_bool(name: &str, default: bool) -> bool {
host::get_config(name)
.and_then(|v| v.parse::<bool>().ok())
.unwrap_or(default)
}
fn read_u64(name: &str, default: u64) -> u64 {
host::get_config(name)
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(default)
}
fn read_string(name: &str, default: &str) -> String {
host::get_config(name).unwrap_or_else(|| default.to_string())
}
fn reload_config() {
CONFIG.with(|cell| {
let mut cfg = cell.borrow_mut();
cfg.enabled = read_bool("enabled", true);
cfg.log_every = read_u64("log_every", 20).max(1);
cfg.label = read_string("label", "wasm config test");
});
}
impl Guest for Component {
fn custom_configs() -> Vec<CustomConfigSection> {
vec![CustomConfigSection {
title: "WASM Config Test".to_string(),
values: vec![
CustomConfigEntry {
name: "enabled".to_string(),
typ: "bool".to_string(),
description: "Enable packet logging from this WASM script".to_string(),
default_value: "true".to_string(),
values: None,
},
CustomConfigEntry {
name: "log_every".to_string(),
typ: "number".to_string(),
description: "Log every N packets. Use 1 to log every packet.".to_string(),
default_value: "20".to_string(),
values: None,
},
CustomConfigEntry {
name: "label".to_string(),
typ: "string".to_string(),
description: "Label printed in WASM info logs".to_string(),
default_value: "wasm config test".to_string(),
values: None,
},
],
}]
}
fn on_create() {
reload_config();
CONFIG.with(|cell| {
let cfg = cell.borrow();
host::info(&format!(
"[wasm-config-test] on_create enabled={} log_every={} label={}",
cfg.enabled, cfg.log_every, cfg.label
));
});
}
fn on_destroy() {
CONFIG.with(|cell| {
let cfg = cell.borrow();
host::info(&format!(
"[wasm-config-test] on_destroy packet_count={}",
cfg.packet_count
));
});
}
fn on_config_changed(name: String, value: String) {
reload_config();
CONFIG.with(|cell| {
let cfg = cell.borrow();
host::info(&format!(
"[wasm-config-test] on_config_changed {}={} -> enabled={} log_every={} label={}",
name, value, cfg.enabled, cfg.log_every, cfg.label
));
});
}
fn ws_script_handler(topic: String, payload: String) -> String {
host::info(&format!(
"[wasm-config-test] ws topic={} payload={}",
topic, payload
));
if topic == "script.get-speed" {
return host::rest_call("GET", "/speed", "");
}
"".to_string()
}
fn modify_packet(_ctx: ModifyContext, pkt: Packet, cfg: ConfigView) -> Decision {
CONFIG.with(|cell| {
let mut rcfg = cell.borrow_mut();
rcfg.packet_count += 1;
if !rcfg.enabled {
return;
}
if rcfg.packet_count % rcfg.log_every == 0 {
let proxy = match pkt.proxy_type {
ProxyType::HeadUnit => "HeadUnit",
ProxyType::MobileDevice => "MobileDevice",
};
host::info(&format!(
"[wasm-config-test] packet_count={} label={} proxy={} channel={} message_id=0x{:04x} payload_len={} developer_mode={}",
rcfg.packet_count,
rcfg.label,
proxy,
pkt.channel,
pkt.message_id,
pkt.payload.len(),
cfg.developer_mode
));
}
});
Decision::Forward
}
}
bindings::export!(Component with_types_in bindings);Expected logs after loading and saving config:
[wasm-config-test] on_create enabled=true log_every=20 label=wasm config test
[wasm-config-test] on_config_changed log_every=1 -> enabled=true log_every=1 label=wasm config test
[wasm-config-test] packet_count=1 label=wasm config test proxy=MobileDevice channel=0 message_id=0x0001 payload_len=42 developer_mode=false
Example request:
{
"type": "script-event",
"topic": "script.get-speed",
"payload": ""
}Example handler:
fn ws_script_handler(topic: String, payload: String) -> String {
if topic == "script.get-speed" {
return host::rest_call("GET", "/speed", "");
}
let _ = payload;
"".to_string()
}Example async REST handler:
fn ws_script_handler(topic: String, payload: String) -> String {
if topic == "script.set-battery" {
let request_id = host::rest_call_async("POST", "/battery", &payload);
let result_topic = host::rest_result_topic();
return format!(
r#"{{"accepted":true,"requestId":"{}","resultTopic":"{}"}}"#,
request_id,
result_topic,
);
}
"".to_string()
}cargo install cargo-component --lockedcargo component build --releaseDepending on your cargo-component / target setup, the output is usually under one of these directories:
target/wasm32-wasip1/release/
target/wasm32-wasip2/release/Example:
ls target/wasm32-wasip*/release/*.wasmcp target/wasm32-wasip*/release/aa_proxy_test_hook.wasm /data/wasm-hooks/test_hook.wasmRestart aa-proxy or trigger script reload if needed.
Look for logs like:
[wasm] loaded wasm script: /data/wasm-hooks/test_hook.wasm
[wasm-config-test] on_create enabled=true log_every=20 label=wasm config test
If the host fails to instantiate the component because of missing WASI imports, inspect the component:
wasm-tools component wit target/wasm32-wasip*/release/aa_proxy_test_hook.wasm | grep wasior:
wasm-tools print target/wasm32-wasip*/release/aa_proxy_test_hook.wasm | grep "wasi:"If the component imports wasi:cli/environment@0.2.x, the host must register WASI Preview 2 support in its component linker.
After changing custom config from the UI, check:
cat /data/aa-proxy-rs/wasm-config.tomlExpected shape:
[script.test_hook]
enabled = "true"
log_every = "1"
label = "wasm config test"- Use
host::info()/host::error()instead ofprintln!()/eprintln!(). - Avoid file IO, OS environment reads, clocks, and random APIs unless the host provides the required WASI imports.
- Keep
modify_packet()fast. - Prefer
ws_script_handler()for low-frequency REST calls and script-level events. - Prefer
rest_call_async()when the result does not need to be returned immediately. - The same
.wasmcomponent can run on different host CPU architectures because WebAssembly component bytecode is architecture-independent. - Guest state is runtime-only. Persist user settings through custom config, not guest memory.
The host should restrict script REST access to safe, low-risk endpoints.
Suggested allowed routes:
POST /battery
POST /odometer
POST /tire-pressure
POST /inject_event
POST /inject_rotary
GET /speed
GET /battery-status
GET /odometer-status
GET /tire-pressure-status
Suggested blocked routes:
/ws
/download
/restart
/reboot
/factory-reset
/upload-certs
/upload-hex-model
/userdata-backup
/userdata-restore
This keeps scripting useful while avoiding destructive or high-risk operations.