[{"data":1,"prerenderedAt":3059},["ShallowReactive",2],{"blog-post-unipwn-2025":3,"blog-all":298},{"id":4,"title":5,"author":6,"authorBio":7,"authorRole":8,"body":9,"category":278,"date":279,"description":93,"excerpt":280,"extension":281,"featured":282,"image":280,"meta":283,"navigation":282,"path":284,"pinned":285,"readTime":286,"seo":287,"slug":288,"stem":289,"summary":290,"tags":291,"tone":295,"updated":296,"__hash__":297},"blog\u002Fen\u002Fblog\u002Fpost-unipwn-2025.md","Post-UniPwn: What the 2025 Bluetooth exploit revealed about Unitree's security model","qtvue security team","Our security team specializes in robotics firmware hardening, fleet policy, and incident response. We do not do jailbreaks or tier unlocks — only legitimate defensive work.","Security & hardening practice",{"type":10,"value":11,"toc":261},"minimark",[12,28,33,74,77,81,84,94,97,100,104,107,128,131,135,138,149,152,156,159,164,170,176,180,183,187,190,194,201,205,208,212,215,222,226,229,249,252],[13,14,15],"blockquote",{},[16,17,18,22,23,27],"p",{},[19,20,21],"strong",{},"TL;DR."," In July 2025, an external researcher disclosed a\npre-authentication Bluetooth Classic attack surface on the Unitree G1\nand H1 that allowed unauthenticated firmware replacement within radio\nrange. Unitree patched it in firmware ",[24,25,26],"code",{},"1.5.2"," (October 2025). The\npatch closes the immediate hole. The underlying supply-chain problem\n— debug firmware accessible from a wireless interface — is a pattern\nthat will recur. This article is what we tell customers.",[29,30,32],"h2",{"id":31},"_1-timeline","1. Timeline",[34,35,36,43,49,59,65],"ul",{},[37,38,39,42],"li",{},[19,40,41],{},"April 2025"," — Internal research discovers the attack surface\nduring a red-team engagement for a warehouse customer.",[37,44,45,48],{},[19,46,47],{},"May 2025"," — Coordinated disclosure initiated with Unitree's\nsecurity contact.",[37,50,51,54,55,58],{},[19,52,53],{},"June 2025"," — Independent researcher (alias ",[24,56,57],{},"@unidrop",") publishes\na similar finding on social media before the coordinated disclosure\nwindow closes.",[37,60,61,64],{},[19,62,63],{},"July 2025"," — Unitree acknowledges the issue publicly, assigns an\ninternal tracking ID, and begins patch development.",[37,66,67,70,71,73],{},[19,68,69],{},"October 2025"," — Firmware ",[24,72,26],{}," ships with the Bluetooth\nattack surface removed. Publicly labeled \"UniPwn 2025\" by the\ncommunity.",[16,75,76],{},"The window between coordinated disclosure and the independent\nresearcher's publication was ~6 weeks. In retrospect, this is on\nthe fast side for robotics vendors and we credit Unitree for the\nquick turnaround.",[29,78,80],{"id":79},"_2-the-attack-surface","2. The attack surface",[16,82,83],{},"The G1 and H1 use a Bluetooth Classic (BR\u002FEDR) radio for service\nand debugging. Specifically:",[85,86,91],"pre",{"className":87,"code":89,"language":90},[88],"language-text","┌────────────────────────────────────────────────────┐\n│ G1 \u002F H1 — Bluetooth stack (pre-1.5.2)              │\n├────────────────────────────────────────────────────┤\n│ 1. Pairing mode: 'Just Works' (no PIN, no confirm) │\n│ 2. Paired device gets:                             │\n│    - Read access to all telemetry (joints, IMU)    │\n│    - Write access to motor commands                │\n│    - **Firmware upload over the BT channel**       │\n│ 3. Pairing requires: radio range only              │\n└────────────────────────────────────────────────────┘\n","text",[24,92,89],{"__ignoreMap":93},"",[16,95,96],{},"The \"Just Works\" pairing mode is a deliberate engineering choice\n— it lets a field technician bring a phone or laptop within range\nand quickly diagnose a robot without pairing workflows. The trade-off\nis that any attacker within radio range (typically 10–30 m, longer\nwith a directional antenna) can pair without authentication.",[16,98,99],{},"The firmware upload path is the critical primitive. With it, an\nattacker can flash a custom firmware that disables safety limits,\nexfiltrates the on-board camera feed, or uses the robot as a\npivot into the corporate LAN.",[29,101,103],{"id":102},"_3-why-it-happened","3. Why it happened",[16,105,106],{},"We do not have visibility into Unitree's internal design review\nprocess, but the underlying pattern is familiar from IoT and OT\nsecurity:",[108,109,110,116,122],"ol",{},[37,111,112,115],{},[19,113,114],{},"Debug interfaces that survive into production."," The BT\nservice is wired to the same SOC as the debug UART and JTAG.\nIt was meant for factory calibration; no one removed it before\nshipping.",[37,117,118,121],{},[19,119,120],{},"Authentication that assumes physical proximity = trust."," Many\nrobots use \"anyone within range can pair\" as the access control\nmodel. This works for a research lab; it doesn't work for a\nwarehouse floor with shared LAN access.",[37,123,124,127],{},[19,125,126],{},"No firmware signature verification."," The bootloader accepts\nany signed firmware, but \"signed\" means \"signed by Unitree's\ndevelopment key\" — there is no chain of trust back to a per-\ndevice key. If you can flash firmware, you can flash any firmware.",[16,129,130],{},"This is not unique to Unitree. We've seen equivalent patterns on\nBoston Dynamics Spot (early firmware), Clearpath Husky, and most\nagricultural robot platforms. The robotics industry is roughly\nwhere Windows was in 2003 from a security-model perspective.",[29,132,134],{"id":133},"_4-what-firmware-152-patches","4. What firmware 1.5.2 patches",[16,136,137],{},"The patch addresses all three of the underlying issues:",[34,139,140,143,146],{},[37,141,142],{},"Bluetooth pairing requires a physical button press on the robot\nto authorize a new device (a 5-second hold of the side button).",[37,144,145],{},"Firmware uploads now require a per-device signature tied to the\nrobot's serial number; firmware signed by Unitree's master key\nis no longer accepted on a different robot without re-signing.",[37,147,148],{},"The BT service is no longer bridged to the motor-control\nsubprocess; it can read telemetry for diagnostics but cannot\nissue motor commands.",[16,150,151],{},"These are good fixes. They don't address every related risk (see\nbelow) but they close the specific UniPwn attack chain.",[29,153,155],{"id":154},"_5-what-fleet-operators-should-still-do","5. What fleet operators should still do",[16,157,158],{},"If you're operating a fleet of G1, H1, or any Unitree platform\npost-UniPwn, the firmware 1.5.2 patch is the floor, not the\nceiling. We recommend the following as a baseline:",[160,161,163],"h3",{"id":162},"_51-verify-youre-on-the-patched-firmware","5.1 Verify you're on the patched firmware",[85,165,168],{"className":166,"code":167,"language":90},[88],"$ ssh unitree@\u003Crobot-ip> 'cat \u002Fetc\u002Funitree\u002Ffirmware.version'\n1.5.2\n",[24,169,167],{"__ignoreMap":93},[16,171,172,173,175],{},"If you see anything before ",[24,174,26],{},", schedule a maintenance window\nto reflash. The 1.5.2 patch is non-breaking for normal operation.",[160,177,179],{"id":178},"_52-isolate-the-robot-network","5.2 Isolate the robot network",[16,181,182],{},"Put robots on a dedicated VLAN with no route to the corporate LAN.\nThis single change defeats the \"robot as a pivot\" attack class\nregardless of firmware status. We use a small managed switch\n(UniFi, Mikrotik, or Cisco) with the robot ports in their own VLAN\nand an explicit \"no inter-VLAN routing\" ACL.",[160,184,186],{"id":185},"_53-disable-bluetooth-when-not-in-service","5.3 Disable Bluetooth when not in service",[16,188,189],{},"For most production deployments, Bluetooth is only needed during\ncommissioning and field service. We ship our customer deployments\nwith a systemd unit that disables the BT service on boot and a\nphysical button + 5-second hold to re-enable it for service\nwindows. This is the operational equivalent of the firmware patch.",[160,191,193],{"id":192},"_54-verify-firmware-signatures-on-every-boot","5.4 Verify firmware signatures on every boot",[16,195,196,197,200],{},"The 1.5.2 patch enables per-device firmware signatures, but\nnothing prevents an attacker from disabling the signature check\nin the bootloader (a different attack, but worth defending\nagainst). We add a ",[24,198,199],{},"secure-boot-verifier"," systemd unit that\nre-checks the signature on every boot and refuses to start\nmotor-control if the check fails.",[160,202,204],{"id":203},"_55-continuous-firmware-monitoring","5.5 Continuous firmware monitoring",[16,206,207],{},"Subscribe to Unitree's security advisories. If you can't get\nRMA-grade response time from the vendor, set up an internal\nadvisory mirror.",[29,209,211],{"id":210},"_6-the-deeper-pattern","6. The deeper pattern",[16,213,214],{},"UniPwn 2025 is not an isolated incident. It's the first widely-\npublicized instance of a class of vulnerability — \"debug interface\nleft in production firmware\" — that will recur across the robotics\nindustry for the next decade. The economic pressure on robot\nmanufacturers to ship features fast is real, and security review\nboards for firmware are still rare.",[16,216,217,218,221],{},"What we tell our customers is this: ",[19,219,220],{},"assume every shipped robot\nhas at least one wireless or physical debug interface left in\nproduction firmware",". Treat the robot as compromised from day one.\nNetwork isolate it. Verify firmware on every boot. Disable wireless\nwhen not in service. This is the operational discipline that turns\n\"vendor will patch eventually\" into \"we're safe right now.\"",[29,223,225],{"id":224},"_7-what-qtvue-ships","7. What qtvue ships",[16,227,228],{},"For customers who engage us on the Security & Hardening service, we\ndeliver:",[34,230,231,234,237,243,246],{},[37,232,233],{},"A fleet-wide firmware audit (which platforms, which firmware\nversions, which are missing the patch).",[37,235,236],{},"A network isolation design for the robot VLAN.",[37,238,239,240,242],{},"A signed-firmware-verification unit (",[24,241,199],{},").",[37,244,245],{},"A continuous-monitoring unit that watches for anomalous DDS\ntraffic patterns from each robot.",[37,247,248],{},"An incident-response retainer: if you suspect compromise, we\nare on a 4-hour response window.",[16,250,251],{},"We do not do jailbreaks or tier unlocks. We do not bypass the\nUnitree partner terms. The work is purely defensive.",[16,253,254,255,260],{},"Reach out via the ",[256,257,259],"a",{"href":258},"\u002Fintake","intake form"," if you want a hardening\nengagement scoped.",{"title":93,"searchDepth":262,"depth":262,"links":263},2,[264,265,266,267,268,276,277],{"id":31,"depth":262,"text":32},{"id":79,"depth":262,"text":80},{"id":102,"depth":262,"text":103},{"id":133,"depth":262,"text":134},{"id":154,"depth":262,"text":155,"children":269},[270,272,273,274,275],{"id":162,"depth":271,"text":163},3,{"id":178,"depth":271,"text":179},{"id":185,"depth":271,"text":186},{"id":192,"depth":271,"text":193},{"id":203,"depth":271,"text":204},{"id":210,"depth":262,"text":211},{"id":224,"depth":262,"text":225},"security","2026-06-15",null,"md",true,{},"\u002Fen\u002Fblog\u002Fpost-unipwn-2025",false,11,{"title":5,"description":93},"post-unipwn-2025","en\u002Fblog\u002Fpost-unipwn-2025","A researcher's walk-through of CVE-style disclosure, the G1 attack surface, what Unitree patched in firmware 1.5.2, and what fleet operators should still do in 2026.",[292,293,294,278],"unipwn","bluetooth","firmware","ink","2026-06-20","XybyQPgTbBdrH223Wut7pnL05DlFF4jYg9hqby7EYBg",[299,847,1963,2134,2472],{"id":300,"title":301,"author":302,"authorBio":303,"authorRole":304,"body":305,"category":832,"date":833,"description":93,"excerpt":280,"extension":281,"featured":285,"image":280,"meta":834,"navigation":282,"path":835,"pinned":285,"readTime":507,"seo":836,"slug":837,"stem":838,"summary":839,"tags":840,"tone":845,"updated":280,"__hash__":846},"blog\u002Fen\u002Fblog\u002Fisaac-lab-sim-to-real.md","Isaac Lab + Unitree G1: training locomotion in a weekend","qtvue engineering","We've trained dozens of sim-to-real policies on the G1. This article is the workflow that consistently produces a policy that transfers without retraining.","Engineering team",{"type":10,"value":306,"toc":819},[307,323,327,330,348,358,362,366,386,390,412,416,422,426,429,564,567,581,585,588,698,705,709,712,717,720,725,728,733,736,741,748,752,755,787,790,794,810,815],[13,308,309,318],{},[16,310,311,313,314,317],{},[19,312,21],{}," The G1 is the first Unitree platform where sim-to-real\nlocomotion is tractable for a small team. The recipe: Isaac Lab\n2.1 + ",[24,315,316],{},"unitree_sim_isaaclab"," + a single A100 + 18 hours of training",[34,319,320],{},[37,321,322],{},"a careful domain randomization sweep. We walk through the whole\nworkflow below, including the four things that don't transfer.",[29,324,326],{"id":325},"_1-why-the-g1-and-why-now","1. Why the G1, and why now",[16,328,329],{},"Earlier Unitree platforms (Go2, H1) had two characteristics that\nmade sim-to-real hard:",[108,331,332,338],{},[37,333,334,337],{},[19,335,336],{},"Proprietary actuator models."," The official URDFs didn't\nexpose the actuator's current loop, so simulated joint dynamics\ndiverged from real by 20–30%.",[37,339,340,343,344,347],{},[19,341,342],{},"No public RL training environments."," Unitree's ",[24,345,346],{},"unitree_ros2","\ngave you the runtime but not the gym environment.",[16,349,350,351,353,354,357],{},"The G1 changed both. The URDF exposes the actuator PD gains as\nconfigurable parameters, and Unitree ships a maintained\n",[24,352,316],{}," repo with a gym environment, asset pack,\nand reward function templates. The policy you train in sim\ntransfers to the real G1 with ",[19,355,356],{},"\u003C 2 hours of fine-tuning"," if\nyou follow the recipe below.",[29,359,361],{"id":360},"_2-the-recipe","2. The recipe",[160,363,365],{"id":364},"_21-hardware","2.1 Hardware",[34,367,368,374,380],{},[37,369,370,373],{},[19,371,372],{},"1× NVIDIA A100 (80 GB)"," — minimum. We use A100s because the\nIsaac Lab physics step is memory-bandwidth-bound, not compute-\nbound; an A100 gives 2 TB\u002Fs HBM2e vs the A10's 1.5 TB\u002Fs. RTX 4090\nworks but takes ~1.5× the wall-clock training time.",[37,375,376,379],{},[19,377,378],{},"32-core CPU"," — Isaac Lab parallelizes across CPU threads for\nthe collision and contact-resolution passes.",[37,381,382,385],{},[19,383,384],{},"256 GB RAM"," — recommended for the domain randomization sweep.",[160,387,389],{"id":388},"_22-software","2.2 Software",[34,391,392,395,398,401,409],{},[37,393,394],{},"Ubuntu 22.04 (Isaac Lab doesn't fully support 24.04 yet as of\nthis writing).",[37,396,397],{},"CUDA 12.4, cuDNN 9.0.",[37,399,400],{},"Isaac Sim 4.2, Isaac Lab 2.1.",[37,402,403,405,406,242],{},[24,404,316],{}," (latest tag, currently ",[24,407,408],{},"v1.3.1",[37,410,411],{},"PyTorch 2.4 with CUDA 12.4 wheels.",[160,413,415],{"id":414},"_23-the-workflow","2.3 The workflow",[85,417,420],{"className":418,"code":419,"language":90},[88],"┌─────────────────────────────────────────────────────┐\n│ Workflow — 48 hours wall-clock                       │\n├──────────┬──────────────────────────────────────────┤\n│  Hour 0  │ Pull unitree_sim_isaaclab, install deps  │\n│  Hour 1  │ Sanity-check: roll out a pre-trained      │\n│          │ policy on the real G1                     │\n│  Hour 2  │ Set up the URDF → USD conversion          │\n│  Hour 4  │ Configure reward function (see §3)        │\n│  Hour 6  │ Configure domain randomization (see §4)  │\n│  Hour 8  │ First training run (sanity, 1k steps)    │\n│  Hour 10 │ First full training run (5M steps)       │\n│  Hour 28 │ First sim-to-real transfer                │\n│  Hour 30 │ On-robot fine-tuning                      │\n│  Hour 36 │ Validation on hardware                   │\n│  Hour 48 │ Policy deployed in customer's fleet       │\n└──────────┴──────────────────────────────────────────┘\n",[24,421,419],{"__ignoreMap":93},[29,423,425],{"id":424},"_3-the-reward-function","3. The reward function",[16,427,428],{},"The reward function is the single most important design decision.\nHere's what we use for forward locomotion:",[85,430,434],{"className":431,"code":432,"language":433,"meta":93,"style":93},"language-python shiki shiki-themes github-light github-dark","def reward_fn(state, action):\n    # Primary: forward velocity tracking\n    v_forward = state.base_lin_vel[:, 0]                  # x-axis\n    v_target = 0.6                                        # m\u002Fs\n    r_tracking = torch.exp(-2.0 * (v_forward - v_target) ** 2)\n\n    # Secondary: stability (penalize angular velocity)\n    r_orientation = torch.exp(-1.0 * torch.norm(state.base_ang_vel, dim=-1))\n\n    # Regularization: action smoothness\n    r_action = -0.001 * torch.norm(action, dim=-1)\n\n    # Regularization: joint limit margin\n    margin = torch.clamp(0.95 - state.joint_pos.abs().max(dim=-1).values, min=0)\n    r_joint_limit = -10.0 * (1.0 - margin \u002F 0.05)\n\n    return (\n        1.0 * r_tracking\n        + 0.5 * r_orientation\n        + r_action\n        + r_joint_limit\n    )\n","python",[24,435,436,444,449,454,460,466,472,478,484,489,495,500,505,511,517,523,528,534,540,546,552,558],{"__ignoreMap":93},[437,438,441],"span",{"class":439,"line":440},"line",1,[437,442,443],{},"def reward_fn(state, action):\n",[437,445,446],{"class":439,"line":262},[437,447,448],{},"    # Primary: forward velocity tracking\n",[437,450,451],{"class":439,"line":271},[437,452,453],{},"    v_forward = state.base_lin_vel[:, 0]                  # x-axis\n",[437,455,457],{"class":439,"line":456},4,[437,458,459],{},"    v_target = 0.6                                        # m\u002Fs\n",[437,461,463],{"class":439,"line":462},5,[437,464,465],{},"    r_tracking = torch.exp(-2.0 * (v_forward - v_target) ** 2)\n",[437,467,469],{"class":439,"line":468},6,[437,470,471],{"emptyLinePlaceholder":282},"\n",[437,473,475],{"class":439,"line":474},7,[437,476,477],{},"    # Secondary: stability (penalize angular velocity)\n",[437,479,481],{"class":439,"line":480},8,[437,482,483],{},"    r_orientation = torch.exp(-1.0 * torch.norm(state.base_ang_vel, dim=-1))\n",[437,485,487],{"class":439,"line":486},9,[437,488,471],{"emptyLinePlaceholder":282},[437,490,492],{"class":439,"line":491},10,[437,493,494],{},"    # Regularization: action smoothness\n",[437,496,497],{"class":439,"line":286},[437,498,499],{},"    r_action = -0.001 * torch.norm(action, dim=-1)\n",[437,501,503],{"class":439,"line":502},12,[437,504,471],{"emptyLinePlaceholder":282},[437,506,508],{"class":439,"line":507},13,[437,509,510],{},"    # Regularization: joint limit margin\n",[437,512,514],{"class":439,"line":513},14,[437,515,516],{},"    margin = torch.clamp(0.95 - state.joint_pos.abs().max(dim=-1).values, min=0)\n",[437,518,520],{"class":439,"line":519},15,[437,521,522],{},"    r_joint_limit = -10.0 * (1.0 - margin \u002F 0.05)\n",[437,524,526],{"class":439,"line":525},16,[437,527,471],{"emptyLinePlaceholder":282},[437,529,531],{"class":439,"line":530},17,[437,532,533],{},"    return (\n",[437,535,537],{"class":439,"line":536},18,[437,538,539],{},"        1.0 * r_tracking\n",[437,541,543],{"class":439,"line":542},19,[437,544,545],{},"        + 0.5 * r_orientation\n",[437,547,549],{"class":439,"line":548},20,[437,550,551],{},"        + r_action\n",[437,553,555],{"class":439,"line":554},21,[437,556,557],{},"        + r_joint_limit\n",[437,559,561],{"class":439,"line":560},22,[437,562,563],{},"    )\n",[16,565,566],{},"Two non-obvious choices:",[34,568,569,575],{},[37,570,571,574],{},[19,572,573],{},"The velocity target is 0.6 m\u002Fs, not the G1's max."," The\nG1's max is 1.5 m\u002Fs, but training for max speed produces a\npolicy that walks stiffly and recovers poorly from pushes.\nTraining for 0.6 m\u002Fs produces a policy that walks naturally\nand is more robust to disturbances.",[37,576,577,580],{},[19,578,579],{},"The joint-limit penalty has a hard cliff at 0.95."," Joints\ncan command up to ±1.0 in normalized space. The penalty kicks\nin at 0.95 (the safe operating range). This prevents the\npolicy from learning \"go to limit, then bounce\" tricks.",[29,582,584],{"id":583},"_4-domain-randomization","4. Domain randomization",[16,586,587],{},"Domain randomization (DR) is what closes the sim-to-real gap.\nWe randomize the following ranges:",[589,590,591,607],"table",{},[592,593,594],"thead",{},[595,596,597,601,604],"tr",{},[598,599,600],"th",{},"Parameter",[598,602,603],{},"Range",[598,605,606],{},"Source",[608,609,610,622,632,643,654,665,676,687],"tbody",{},[595,611,612,616,619],{},[613,614,615],"td",{},"Joint friction",[613,617,618],{},"0.5–2.0 N·m·s",[613,620,621],{},"measured on 3 customer G1s",[595,623,624,627,630],{},[613,625,626],{},"Joint damping",[613,628,629],{},"0.1–0.5 N·m·s",[613,631,621],{},[595,633,634,637,640],{},[613,635,636],{},"Link mass",[613,638,639],{},"0.8–1.2 × nominal",[613,641,642],{},"URDF + ±10%",[595,644,645,648,651],{},[613,646,647],{},"Center of mass offset",[613,649,650],{},"±5 cm per link",[613,652,653],{},"payload variation",[595,655,656,659,662],{},[613,657,658],{},"Ground friction",[613,660,661],{},"0.5–1.2",[613,663,664],{},"measured across tile \u002F concrete \u002F rubber mat",[595,666,667,670,673],{},[613,668,669],{},"Push force",[613,671,672],{},"0–30 N, applied 1×\u002Fsec",[613,674,675],{},"mimics real disturbances",[595,677,678,681,684],{},[613,679,680],{},"Action latency",[613,682,683],{},"0–20 ms",[613,685,686],{},"matches DDS round-trip observed",[595,688,689,692,695],{},[613,690,691],{},"Observation noise",[613,693,694],{},"±0.01 rad on joint pos",[613,696,697],{},"measured on RealSense + IMU",[16,699,700,701,704],{},"The ",[19,702,703],{},"action latency"," randomization is the most important and\nmost overlooked. The DDS round-trip from observation to action\nvaries between 5 ms (idle) and 25 ms (under load). If you train\nwith zero latency and deploy with 20 ms, the policy oscillates\nand falls over. Always randomize.",[29,706,708],{"id":707},"_5-what-doesnt-transfer","5. What doesn't transfer",[16,710,711],{},"These four things consistently bite us:",[16,713,714],{},[19,715,716],{},"5.1 Cable tension.",[16,718,719],{},"Real G1s have a power cable and (often) an Ethernet cable. The\ncable adds a constraint force the policy doesn't see in sim.\nFix: add the cable as a soft constraint with a randomized anchor\npoint in sim, OR use wireless deployment only.",[16,721,722],{},[19,723,724],{},"5.2 Ground transitions.",[16,726,727],{},"Sim-to-real on flat ground transfers well. Sim-to-real over\nthreshold transitions (e.g., G1 stepping off a 5 cm platform edge)\nfails ~40% of the time. Fix: add a small \"terrain curriculum\" with\nrandomized step heights from 0–8 cm. Increases training time by\n~30%.",[16,729,730],{},[19,731,732],{},"5.3 Lighting changes.",[16,734,735],{},"Vision-based policies transfer poorly when the lighting changes\nbetween training and deployment. We don't have a clean fix for\nthis yet; one option is to train with randomized HDR backgrounds.",[16,737,738],{},[19,739,740],{},"5.4 Mechanical wear.",[16,742,743,744,747],{},"A real G1 that has been walked for 100 hours has slightly different\njoint dynamics than a fresh G1. If you deploy to a fleet, expect\n~5% performance degradation per 100 hours of operation. Fix:\nre-calibrate the actuator PD gains every 200 hours of use, OR\nadd a small ",[24,745,746],{},"pd-tuning"," environment step in sim.",[29,749,751],{"id":750},"_6-validation","6. Validation",[16,753,754],{},"We validate every policy on the actual G1 hardware before\ndeploying it to a customer fleet. The validation protocol is:",[108,756,757,763,769,775,781],{},[37,758,759,762],{},[19,760,761],{},"Bench test"," — policy runs on the bench for 5 minutes, no\nobstacles. Check for oscillation or drift.",[37,764,765,768],{},[19,766,767],{},"Flat ground"," — 10 m walk, 3 trials. Check final position\nerror.",[37,770,771,774],{},[19,772,773],{},"Push recovery"," — 10 N lateral push every 2 seconds for 1\nminute. Check recovery time.",[37,776,777,780],{},[19,778,779],{},"Threshold test"," — 3 cm and 6 cm threshold, 5 trials each.\nCheck success rate.",[37,782,783,786],{},[19,784,785],{},"Endurance"," — 30 minutes continuous walk. Check battery and\nthermal.",[16,788,789],{},"If the policy fails any test, we go back to training with\nexpanded DR or modified reward weights. Typically 1–2 iterations\nto convergence.",[29,791,793],{"id":792},"_7-when-sim-to-real-is-the-wrong-tool","7. When sim-to-real is the wrong tool",[16,795,796,797,800,801,804,805,809],{},"Honest caveat: sim-to-real is the right tool for ",[19,798,799],{},"locomotion\nand balance",". It's the wrong tool for ",[19,802,803],{},"manipulation",", where\ncontact dynamics are too complex to simulate accurately. For\nmanipulation policies, we use ",[256,806,808],{"href":807},"\u002Fblog\u002Flerobot-meets-unitree-go2","LeRobot","\n(imitation learning) or teleoperation data collection.",[16,811,254,812,814],{},[256,813,259],{"href":258}," if you have a sim-to-\nreal problem to scope.",[816,817,818],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":93,"searchDepth":262,"depth":262,"links":820},[821,822,827,828,829,830,831],{"id":325,"depth":262,"text":326},{"id":360,"depth":262,"text":361,"children":823},[824,825,826],{"id":364,"depth":271,"text":365},{"id":388,"depth":271,"text":389},{"id":414,"depth":271,"text":415},{"id":424,"depth":262,"text":425},{"id":583,"depth":262,"text":584},{"id":707,"depth":262,"text":708},{"id":750,"depth":262,"text":751},{"id":792,"depth":262,"text":793},"tutorial","2026-06-08",{},"\u002Fen\u002Fblog\u002Fisaac-lab-sim-to-real",{"title":301,"description":93},"isaac-lab-sim-to-real","en\u002Fblog\u002Fisaac-lab-sim-to-real","A reproducible workflow for going from a fresh Isaac Lab install to a sim-to-real transferred G1 policy in 48 hours. We share the URDF, the reward function, the domain randomization ranges, and what doesn't transfer.",[841,842,843,844],"isaac-lab","sim-to-real","rl","unitree-g1","cream","v0vkqiK0_I8pV85BYCm4GjBQeEQAooS92SO0fWEZ16Q",{"id":848,"title":849,"author":302,"authorBio":850,"authorRole":304,"body":851,"category":832,"date":1949,"description":93,"excerpt":280,"extension":281,"featured":285,"image":280,"meta":1950,"navigation":282,"path":1951,"pinned":285,"readTime":491,"seo":1952,"slug":1953,"stem":1954,"summary":1955,"tags":1956,"tone":1961,"updated":280,"__hash__":1962},"blog\u002Fen\u002Fblog\u002Flerobot-meets-unitree-go2.md","LeRobot meets Unitree Go2: 50 demonstrations to a walking policy","We've deployed imitation-learned locomotion on more than a dozen customer Go2 deployments. This is the workflow.",{"type":10,"value":852,"toc":1931},[853,864,868,876,879,883,885,921,923,1019,1023,1027,1034,1048,1409,1413,1416,1448,1455,1459,1463,1466,1534,1537,1548,1552,1680,1687,1691,1694,1828,1831,1835,1838,1899,1902,1906,1909,1915,1922,1928],[13,854,855],{},[16,856,857,859,860,863],{},[19,858,21],{}," Imitation learning (IL) outperforms reinforcement learning\nfor quadruped locomotion over rough terrain. We use LeRobot + the\nUnitree Go2's native SDK to collect 50 teleoperated demonstrations,\ntrain an ACT (Action Chunking Transformer) policy for 6 hours on\n4× A100, and deploy back to the Go2 over the ",[24,861,862],{},"unitree_sdk2"," DDS\nbridge. The policy transfers without fine-tuning and outperforms\nthe built-in sport-mode controller on uneven terrain by 3–5×.",[29,865,867],{"id":866},"_1-why-il-beats-rl-on-rough-terrain","1. Why IL beats RL on rough terrain",[16,869,870,871,875],{},"We tried both. RL-trained policies on the Go2 work great on flat\nground (and we use sim-to-real for that — see ",[256,872,874],{"href":873},"\u002Fblog\u002Fisaac-lab-sim-to-real","the Isaac Lab\nrecipe","). They fail on rough terrain\nbecause the contact dynamics of a quadruped on uneven ground are\ntoo varied to enumerate in a reward function. The policy either\nplays it too safe (refuses to step over small obstacles) or\ngets too aggressive (falls on the first unexpected foothold).",[16,877,878],{},"Imitation learning sidesteps this by demonstration. We collect\n50 teleoperated trajectories of a human walking the Go2 across\nthe target terrain. The policy learns to imitate the human's\nhigh-level strategy: which foot goes where, how to recover from\na stumble, how to redistribute weight over uneven ground. These\nare hard to express as rewards; they're easy to demonstrate.",[29,880,882],{"id":881},"_2-the-setup","2. The setup",[160,884,365],{"id":364},[34,886,887,893,899,909,915],{},[37,888,889,892],{},[19,890,891],{},"1× Unitree Go2"," (the robot we're training on)",[37,894,895,898],{},[19,896,897],{},"1× Go2 controller"," (the handheld joystick that ships with\nthe robot — used for teleop)",[37,900,901,904,905,908],{},[19,902,903],{},"1× host PC"," with the Go2 SDK installed (Ubuntu 22.04 +\n",[24,906,907],{},"unitree_sdk2_python",")",[37,910,911,914],{},[19,912,913],{},"4× A100 (80 GB)"," for training (a single A100 works but\ntakes ~3× wall-clock)",[37,916,917,920],{},[19,918,919],{},"1× RealSense D435i"," mounted on the Go2 (we use the existing\nhead-mount; no extra hardware)",[160,922,389],{"id":388},[85,924,928],{"className":925,"code":926,"language":927,"meta":93,"style":93},"language-bash shiki shiki-themes github-light github-dark","# Install LeRobot (latest stable)\npip install lerobot==0.4.1\n\n# Install the Unitree DDS bridge\npip install unitree_sdk2py==2.4.0 cyclonedds==0.10.2\n\n# Verify both work together\npython -c \"\nfrom lerobot.common.datasets.lerobot_dataset import LeRobotDataset\nfrom unitree_sdk2py.go2.sport.sport_client import SportClient\nprint('OK')\n\"\n","bash",[24,929,930,936,953,957,962,980,984,989,999,1004,1009,1014],{"__ignoreMap":93},[437,931,932],{"class":439,"line":440},[437,933,935],{"class":934},"sJ8bj","# Install LeRobot (latest stable)\n",[437,937,938,942,946,949],{"class":439,"line":262},[437,939,941],{"class":940},"sScJk","pip",[437,943,945],{"class":944},"sZZnC"," install",[437,947,948],{"class":944}," lerobot==",[437,950,952],{"class":951},"sj4cs","0.4.1\n",[437,954,955],{"class":439,"line":271},[437,956,471],{"emptyLinePlaceholder":282},[437,958,959],{"class":439,"line":456},[437,960,961],{"class":934},"# Install the Unitree DDS bridge\n",[437,963,964,966,968,971,974,977],{"class":439,"line":462},[437,965,941],{"class":940},[437,967,945],{"class":944},[437,969,970],{"class":944}," unitree_sdk2py==",[437,972,973],{"class":951},"2.4.0",[437,975,976],{"class":944}," cyclonedds==",[437,978,979],{"class":951},"0.10.2\n",[437,981,982],{"class":439,"line":468},[437,983,471],{"emptyLinePlaceholder":282},[437,985,986],{"class":439,"line":474},[437,987,988],{"class":934},"# Verify both work together\n",[437,990,991,993,996],{"class":439,"line":480},[437,992,433],{"class":940},[437,994,995],{"class":951}," -c",[437,997,998],{"class":944}," \"\n",[437,1000,1001],{"class":439,"line":486},[437,1002,1003],{"class":944},"from lerobot.common.datasets.lerobot_dataset import LeRobotDataset\n",[437,1005,1006],{"class":439,"line":491},[437,1007,1008],{"class":944},"from unitree_sdk2py.go2.sport.sport_client import SportClient\n",[437,1010,1011],{"class":439,"line":286},[437,1012,1013],{"class":944},"print('OK')\n",[437,1015,1016],{"class":439,"line":502},[437,1017,1018],{"class":944},"\"\n",[29,1020,1022],{"id":1021},"_3-data-collection","3. Data collection",[160,1024,1026],{"id":1025},"_31-the-recording-script","3.1 The recording script",[16,1028,1029,1030,1033],{},"We ship a single ",[24,1031,1032],{},"record_walk.py"," script that:",[108,1035,1036,1039,1042,1045],{},[37,1037,1038],{},"Connects to the Go2 over DDS",[37,1040,1041],{},"Subscribes to the front camera + joint state stream",[37,1043,1044],{},"Reads commands from the Go2 controller",[37,1046,1047],{},"Saves each (observation, action) pair to disk",[85,1049,1051],{"className":431,"code":1050,"language":433,"meta":93,"style":93},"# record_walk.py — simplified version of what we use in production\nimport argparse\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom unitree_sdk2py.go2.sport.sport_client import SportClient\nfrom lerobot.common.datasets.lerobot_dataset import LeRobotDataset\n\ndef main():\n    ap = argparse.ArgumentParser()\n    ap.add_argument(\"--output-dir\", required=True)\n    ap.add_argument(\"--episode-seconds\", type=int, default=30)\n    args = ap.parse_args()\n\n    # Connect to the Go2\n    sport = SportClient()\n    sport.SetTimeout(5.0)\n    sport.Init()\n\n    # Initialize the dataset (LeRobot manages disk format)\n    dataset = LeRobotDataset.create(\n        repo_id=\"go2-walk-policy\",\n        root=Path(args.output_dir),\n        fps=30,\n        features={\n            \"observation.image\":       (3, 480, 640),     # front RGB\n            \"observation.joint_pos\":   (12,),             # 12 joints\n            \"observation.base_lin_vel\": (3,),\n            \"observation.base_ang_vel\": (3,),\n            \"action.joint_pos\":        (12,),             # commanded joints\n            \"action.velocity\":         (3,),              # commanded (vx, vy, vyaw)\n        },\n    )\n\n    print(f\"Recording 1 episode for {args.episode_seconds}s. Walk the Go2.\")\n    sport.Walk(True)\n    sport.BalanceStand(True)\n    time.sleep(1.0)\n\n    frames = []\n    t0 = time.time()\n    while time.time() - t0 \u003C args.episode_seconds:\n        obs = {\n            \"observation.image\":        get_front_camera_frame(),\n            \"observation.joint_pos\":    get_joint_positions(),\n            \"observation.base_lin_vel\": sport.GetBaseLinVel(),\n            \"observation.base_ang_vel\": sport.GetBaseAngVel(),\n        }\n        cmd = read_controller()           # (vx, vy, vyaw, joint_targets)\n        action = {\n            \"action.joint_pos\": cmd.joint_targets,\n            \"action.velocity\":  cmd.velocity,\n        }\n        frames.append({\"observation\": obs, \"action\": action})\n        sport.Move(*cmd.velocity)\n        time.sleep(1\u002F30)\n\n    dataset.add_episode(frames)\n    dataset.push_to_hub() if \"--push\" in sys.argv else None\n    sport.Damp()\n    print(f\"Done. {len(frames)} frames saved.\")\n\nif __name__ == \"__main__\":\n    main()\n",[24,1052,1053,1058,1063,1068,1073,1077,1082,1086,1090,1094,1099,1104,1109,1114,1119,1123,1128,1133,1138,1143,1147,1152,1157,1163,1169,1175,1181,1187,1193,1199,1205,1211,1217,1223,1228,1233,1239,1245,1251,1257,1262,1268,1274,1280,1286,1292,1298,1304,1310,1316,1322,1328,1334,1340,1345,1351,1357,1363,1368,1374,1380,1386,1392,1397,1403],{"__ignoreMap":93},[437,1054,1055],{"class":439,"line":440},[437,1056,1057],{},"# record_walk.py — simplified version of what we use in production\n",[437,1059,1060],{"class":439,"line":262},[437,1061,1062],{},"import argparse\n",[437,1064,1065],{"class":439,"line":271},[437,1066,1067],{},"import time\n",[437,1069,1070],{"class":439,"line":456},[437,1071,1072],{},"from pathlib import Path\n",[437,1074,1075],{"class":439,"line":462},[437,1076,471],{"emptyLinePlaceholder":282},[437,1078,1079],{"class":439,"line":468},[437,1080,1081],{},"import numpy as np\n",[437,1083,1084],{"class":439,"line":474},[437,1085,1008],{},[437,1087,1088],{"class":439,"line":480},[437,1089,1003],{},[437,1091,1092],{"class":439,"line":486},[437,1093,471],{"emptyLinePlaceholder":282},[437,1095,1096],{"class":439,"line":491},[437,1097,1098],{},"def main():\n",[437,1100,1101],{"class":439,"line":286},[437,1102,1103],{},"    ap = argparse.ArgumentParser()\n",[437,1105,1106],{"class":439,"line":502},[437,1107,1108],{},"    ap.add_argument(\"--output-dir\", required=True)\n",[437,1110,1111],{"class":439,"line":507},[437,1112,1113],{},"    ap.add_argument(\"--episode-seconds\", type=int, default=30)\n",[437,1115,1116],{"class":439,"line":513},[437,1117,1118],{},"    args = ap.parse_args()\n",[437,1120,1121],{"class":439,"line":519},[437,1122,471],{"emptyLinePlaceholder":282},[437,1124,1125],{"class":439,"line":525},[437,1126,1127],{},"    # Connect to the Go2\n",[437,1129,1130],{"class":439,"line":530},[437,1131,1132],{},"    sport = SportClient()\n",[437,1134,1135],{"class":439,"line":536},[437,1136,1137],{},"    sport.SetTimeout(5.0)\n",[437,1139,1140],{"class":439,"line":542},[437,1141,1142],{},"    sport.Init()\n",[437,1144,1145],{"class":439,"line":548},[437,1146,471],{"emptyLinePlaceholder":282},[437,1148,1149],{"class":439,"line":554},[437,1150,1151],{},"    # Initialize the dataset (LeRobot manages disk format)\n",[437,1153,1154],{"class":439,"line":560},[437,1155,1156],{},"    dataset = LeRobotDataset.create(\n",[437,1158,1160],{"class":439,"line":1159},23,[437,1161,1162],{},"        repo_id=\"go2-walk-policy\",\n",[437,1164,1166],{"class":439,"line":1165},24,[437,1167,1168],{},"        root=Path(args.output_dir),\n",[437,1170,1172],{"class":439,"line":1171},25,[437,1173,1174],{},"        fps=30,\n",[437,1176,1178],{"class":439,"line":1177},26,[437,1179,1180],{},"        features={\n",[437,1182,1184],{"class":439,"line":1183},27,[437,1185,1186],{},"            \"observation.image\":       (3, 480, 640),     # front RGB\n",[437,1188,1190],{"class":439,"line":1189},28,[437,1191,1192],{},"            \"observation.joint_pos\":   (12,),             # 12 joints\n",[437,1194,1196],{"class":439,"line":1195},29,[437,1197,1198],{},"            \"observation.base_lin_vel\": (3,),\n",[437,1200,1202],{"class":439,"line":1201},30,[437,1203,1204],{},"            \"observation.base_ang_vel\": (3,),\n",[437,1206,1208],{"class":439,"line":1207},31,[437,1209,1210],{},"            \"action.joint_pos\":        (12,),             # commanded joints\n",[437,1212,1214],{"class":439,"line":1213},32,[437,1215,1216],{},"            \"action.velocity\":         (3,),              # commanded (vx, vy, vyaw)\n",[437,1218,1220],{"class":439,"line":1219},33,[437,1221,1222],{},"        },\n",[437,1224,1226],{"class":439,"line":1225},34,[437,1227,563],{},[437,1229,1231],{"class":439,"line":1230},35,[437,1232,471],{"emptyLinePlaceholder":282},[437,1234,1236],{"class":439,"line":1235},36,[437,1237,1238],{},"    print(f\"Recording 1 episode for {args.episode_seconds}s. Walk the Go2.\")\n",[437,1240,1242],{"class":439,"line":1241},37,[437,1243,1244],{},"    sport.Walk(True)\n",[437,1246,1248],{"class":439,"line":1247},38,[437,1249,1250],{},"    sport.BalanceStand(True)\n",[437,1252,1254],{"class":439,"line":1253},39,[437,1255,1256],{},"    time.sleep(1.0)\n",[437,1258,1260],{"class":439,"line":1259},40,[437,1261,471],{"emptyLinePlaceholder":282},[437,1263,1265],{"class":439,"line":1264},41,[437,1266,1267],{},"    frames = []\n",[437,1269,1271],{"class":439,"line":1270},42,[437,1272,1273],{},"    t0 = time.time()\n",[437,1275,1277],{"class":439,"line":1276},43,[437,1278,1279],{},"    while time.time() - t0 \u003C args.episode_seconds:\n",[437,1281,1283],{"class":439,"line":1282},44,[437,1284,1285],{},"        obs = {\n",[437,1287,1289],{"class":439,"line":1288},45,[437,1290,1291],{},"            \"observation.image\":        get_front_camera_frame(),\n",[437,1293,1295],{"class":439,"line":1294},46,[437,1296,1297],{},"            \"observation.joint_pos\":    get_joint_positions(),\n",[437,1299,1301],{"class":439,"line":1300},47,[437,1302,1303],{},"            \"observation.base_lin_vel\": sport.GetBaseLinVel(),\n",[437,1305,1307],{"class":439,"line":1306},48,[437,1308,1309],{},"            \"observation.base_ang_vel\": sport.GetBaseAngVel(),\n",[437,1311,1313],{"class":439,"line":1312},49,[437,1314,1315],{},"        }\n",[437,1317,1319],{"class":439,"line":1318},50,[437,1320,1321],{},"        cmd = read_controller()           # (vx, vy, vyaw, joint_targets)\n",[437,1323,1325],{"class":439,"line":1324},51,[437,1326,1327],{},"        action = {\n",[437,1329,1331],{"class":439,"line":1330},52,[437,1332,1333],{},"            \"action.joint_pos\": cmd.joint_targets,\n",[437,1335,1337],{"class":439,"line":1336},53,[437,1338,1339],{},"            \"action.velocity\":  cmd.velocity,\n",[437,1341,1343],{"class":439,"line":1342},54,[437,1344,1315],{},[437,1346,1348],{"class":439,"line":1347},55,[437,1349,1350],{},"        frames.append({\"observation\": obs, \"action\": action})\n",[437,1352,1354],{"class":439,"line":1353},56,[437,1355,1356],{},"        sport.Move(*cmd.velocity)\n",[437,1358,1360],{"class":439,"line":1359},57,[437,1361,1362],{},"        time.sleep(1\u002F30)\n",[437,1364,1366],{"class":439,"line":1365},58,[437,1367,471],{"emptyLinePlaceholder":282},[437,1369,1371],{"class":439,"line":1370},59,[437,1372,1373],{},"    dataset.add_episode(frames)\n",[437,1375,1377],{"class":439,"line":1376},60,[437,1378,1379],{},"    dataset.push_to_hub() if \"--push\" in sys.argv else None\n",[437,1381,1383],{"class":439,"line":1382},61,[437,1384,1385],{},"    sport.Damp()\n",[437,1387,1389],{"class":439,"line":1388},62,[437,1390,1391],{},"    print(f\"Done. {len(frames)} frames saved.\")\n",[437,1393,1395],{"class":439,"line":1394},63,[437,1396,471],{"emptyLinePlaceholder":282},[437,1398,1400],{"class":439,"line":1399},64,[437,1401,1402],{},"if __name__ == \"__main__\":\n",[437,1404,1406],{"class":439,"line":1405},65,[437,1407,1408],{},"    main()\n",[160,1410,1412],{"id":1411},"_32-the-recording-protocol","3.2 The recording protocol",[16,1414,1415],{},"We collect 50 episodes over 2–3 hours with one operator. The\nprotocol:",[34,1417,1418,1424,1430,1436,1442],{},[37,1419,1420,1423],{},[19,1421,1422],{},"Warm-up (5 episodes, discard):"," Operator gets comfortable\nwith the controller; robot gets warm.",[37,1425,1426,1429],{},[19,1427,1428],{},"Slow walk (10 episodes, 30s each):"," Operator walks the Go2\nforward at ~0.3 m\u002Fs on flat ground. This is the \"easy\" data.",[37,1431,1432,1435],{},[19,1433,1434],{},"Obstacle course (15 episodes):"," Operator walks the Go2\nover a 3-step obstacle course (5 cm, 8 cm, 12 cm thresholds,\nmixed order). This is the \"hard\" data.",[37,1437,1438,1441],{},[19,1439,1440],{},"Slope (10 episodes):"," Operator walks the Go2 up a 10°\nramp. This teaches pitch compensation.",[37,1443,1444,1447],{},[19,1445,1446],{},"Recovery (10 episodes):"," Operator intentionally pushes the\nGo2 mid-walk; the operator must recover. This teaches the\npolicy to be robust to disturbances.",[16,1449,1450,1451,1454],{},"After 50 episodes, you have ",[19,1452,1453],{},"~25 minutes of demonstration",",\nwhich is enough for an ACT policy to learn rough-terrain locomotion\nthat transfers.",[29,1456,1458],{"id":1457},"_4-training","4. Training",[160,1460,1462],{"id":1461},"_41-why-act","4.1 Why ACT",[16,1464,1465],{},"We benchmarked three policies on the same 50-episode dataset:",[589,1467,1468,1484],{},[592,1469,1470],{},[595,1471,1472,1475,1478,1481],{},[598,1473,1474],{},"Policy",[598,1476,1477],{},"Train time",[598,1479,1480],{},"Sim success",[598,1482,1483],{},"Real success",[608,1485,1486,1502,1518],{},[595,1487,1488,1493,1496,1499],{},[613,1489,1490],{},[19,1491,1492],{},"MLP (baseline)",[613,1494,1495],{},"30 min",[613,1497,1498],{},"92%",[613,1500,1501],{},"41%",[595,1503,1504,1509,1512,1515],{},[613,1505,1506],{},[19,1507,1508],{},"Diffusion Policy",[613,1510,1511],{},"12 h",[613,1513,1514],{},"96%",[613,1516,1517],{},"71%",[595,1519,1520,1525,1528,1531],{},[613,1521,1522],{},[19,1523,1524],{},"ACT",[613,1526,1527],{},"6 h",[613,1529,1530],{},"98%",[613,1532,1533],{},"89%",[16,1535,1536],{},"ACT (Action Chunking Transformer) wins on this task because:",[108,1538,1539,1542,1545],{},[37,1540,1541],{},"It predicts chunks of 100 actions at a time, which is enough\nhorizon for a quadruped stride.",[37,1543,1544],{},"It uses a small transformer (6M params) that trains fast.",[37,1546,1547],{},"It generalizes well from 50 demos because the chunking provides\nimplicit temporal regularization.",[160,1549,1551],{"id":1550},"_42-the-training-config","4.2 The training config",[85,1553,1555],{"className":431,"code":1554,"language":433,"meta":93,"style":93},"# train_act.py\nfrom lerobot.common.policies.act.configuration_act import ACTConfig\nfrom lerobot.common.policies.act.modeling_act import ACTPolicy\n\ncfg = ACTConfig(\n    n_obs_steps=1,                  # single-frame observation\n    chunk_size=100,                 # predict 100 actions at a time\n    n_action_steps=100,\n    dim_model=512,\n    n_encoder_layers=4,\n    n_decoder_layers=1,\n    n_heads=8,\n    dropout=0.1,\n    # Training\n    batch_size=32,\n    lr=1e-4,\n    weight_decay=1e-4,\n    lr_scheduler=\"cosine\",\n    warmup_steps=500,\n    training_steps=50_000,\n)\n\npolicy = ACTPolicy(cfg)\npolicy.train()\ntrainer.train(policy, dataset)\n",[24,1556,1557,1562,1567,1572,1576,1581,1586,1591,1596,1601,1606,1611,1616,1621,1626,1631,1636,1641,1646,1651,1656,1661,1665,1670,1675],{"__ignoreMap":93},[437,1558,1559],{"class":439,"line":440},[437,1560,1561],{},"# train_act.py\n",[437,1563,1564],{"class":439,"line":262},[437,1565,1566],{},"from lerobot.common.policies.act.configuration_act import ACTConfig\n",[437,1568,1569],{"class":439,"line":271},[437,1570,1571],{},"from lerobot.common.policies.act.modeling_act import ACTPolicy\n",[437,1573,1574],{"class":439,"line":456},[437,1575,471],{"emptyLinePlaceholder":282},[437,1577,1578],{"class":439,"line":462},[437,1579,1580],{},"cfg = ACTConfig(\n",[437,1582,1583],{"class":439,"line":468},[437,1584,1585],{},"    n_obs_steps=1,                  # single-frame observation\n",[437,1587,1588],{"class":439,"line":474},[437,1589,1590],{},"    chunk_size=100,                 # predict 100 actions at a time\n",[437,1592,1593],{"class":439,"line":480},[437,1594,1595],{},"    n_action_steps=100,\n",[437,1597,1598],{"class":439,"line":486},[437,1599,1600],{},"    dim_model=512,\n",[437,1602,1603],{"class":439,"line":491},[437,1604,1605],{},"    n_encoder_layers=4,\n",[437,1607,1608],{"class":439,"line":286},[437,1609,1610],{},"    n_decoder_layers=1,\n",[437,1612,1613],{"class":439,"line":502},[437,1614,1615],{},"    n_heads=8,\n",[437,1617,1618],{"class":439,"line":507},[437,1619,1620],{},"    dropout=0.1,\n",[437,1622,1623],{"class":439,"line":513},[437,1624,1625],{},"    # Training\n",[437,1627,1628],{"class":439,"line":519},[437,1629,1630],{},"    batch_size=32,\n",[437,1632,1633],{"class":439,"line":525},[437,1634,1635],{},"    lr=1e-4,\n",[437,1637,1638],{"class":439,"line":530},[437,1639,1640],{},"    weight_decay=1e-4,\n",[437,1642,1643],{"class":439,"line":536},[437,1644,1645],{},"    lr_scheduler=\"cosine\",\n",[437,1647,1648],{"class":439,"line":542},[437,1649,1650],{},"    warmup_steps=500,\n",[437,1652,1653],{"class":439,"line":548},[437,1654,1655],{},"    training_steps=50_000,\n",[437,1657,1658],{"class":439,"line":554},[437,1659,1660],{},")\n",[437,1662,1663],{"class":439,"line":560},[437,1664,471],{"emptyLinePlaceholder":282},[437,1666,1667],{"class":439,"line":1159},[437,1668,1669],{},"policy = ACTPolicy(cfg)\n",[437,1671,1672],{"class":439,"line":1165},[437,1673,1674],{},"policy.train()\n",[437,1676,1677],{"class":439,"line":1171},[437,1678,1679],{},"trainer.train(policy, dataset)\n",[16,1681,1682,1683,1686],{},"50k steps on 4× A100 takes ",[19,1684,1685],{},"~6 hours",". The policy starts\nwalking by step 5k (in simulation). Sim success rate hits\n98% by step 30k. Real-world transfer happens at the end with\nno fine-tuning.",[29,1688,1690],{"id":1689},"_5-deployment","5. Deployment",[16,1692,1693],{},"The deployment is straightforward — load the trained policy, run\nit in a loop, send the predicted actions over DDS:",[85,1695,1697],{"className":431,"code":1696,"language":433,"meta":93,"style":93},"# deploy_act.py\nfrom lerobot.common.policies.act.modeling_act import ACTPolicy\nimport torch\nfrom unitree_sdk2py.go2.sport.sport_client import SportClient\n\npolicy = ACTPolicy.from_pretrained(\".\u002Foutput\u002Fact-go2-walk\u002Fcheckpoints\u002Flast\")\npolicy.eval()\nsport = SportClient(); sport.SetTimeout(5.0); sport.Init()\nsport.Walk(True); sport.BalanceStand(True)\n\nwhile True:\n    obs = {\n        \"observation.image\":        get_front_camera_frame(),\n        \"observation.joint_pos\":    get_joint_positions(),\n        \"observation.base_lin_vel\": sport.GetBaseLinVel(),\n        \"observation.base_ang_vel\": sport.GetBaseAngVel(),\n    }\n    with torch.no_grad():\n        actions = policy.select_action(obs)         # (100, 15)\n\n    # Apply the first 30 actions (1 second of behavior at 30 Hz),\n    # then re-plan. This is the temporal ensembling trick from ACT.\n    for i in range(30):\n        cmd = actions[i]\n        sport.Move(*cmd[:3].cpu().numpy())         # velocity\n        apply_joint_targets(cmd[3:].cpu().numpy())\n        time.sleep(1\u002F30)\n",[24,1698,1699,1704,1708,1713,1717,1721,1726,1731,1736,1741,1745,1750,1755,1760,1765,1770,1775,1780,1785,1790,1794,1799,1804,1809,1814,1819,1824],{"__ignoreMap":93},[437,1700,1701],{"class":439,"line":440},[437,1702,1703],{},"# deploy_act.py\n",[437,1705,1706],{"class":439,"line":262},[437,1707,1571],{},[437,1709,1710],{"class":439,"line":271},[437,1711,1712],{},"import torch\n",[437,1714,1715],{"class":439,"line":456},[437,1716,1008],{},[437,1718,1719],{"class":439,"line":462},[437,1720,471],{"emptyLinePlaceholder":282},[437,1722,1723],{"class":439,"line":468},[437,1724,1725],{},"policy = ACTPolicy.from_pretrained(\".\u002Foutput\u002Fact-go2-walk\u002Fcheckpoints\u002Flast\")\n",[437,1727,1728],{"class":439,"line":474},[437,1729,1730],{},"policy.eval()\n",[437,1732,1733],{"class":439,"line":480},[437,1734,1735],{},"sport = SportClient(); sport.SetTimeout(5.0); sport.Init()\n",[437,1737,1738],{"class":439,"line":486},[437,1739,1740],{},"sport.Walk(True); sport.BalanceStand(True)\n",[437,1742,1743],{"class":439,"line":491},[437,1744,471],{"emptyLinePlaceholder":282},[437,1746,1747],{"class":439,"line":286},[437,1748,1749],{},"while True:\n",[437,1751,1752],{"class":439,"line":502},[437,1753,1754],{},"    obs = {\n",[437,1756,1757],{"class":439,"line":507},[437,1758,1759],{},"        \"observation.image\":        get_front_camera_frame(),\n",[437,1761,1762],{"class":439,"line":513},[437,1763,1764],{},"        \"observation.joint_pos\":    get_joint_positions(),\n",[437,1766,1767],{"class":439,"line":519},[437,1768,1769],{},"        \"observation.base_lin_vel\": sport.GetBaseLinVel(),\n",[437,1771,1772],{"class":439,"line":525},[437,1773,1774],{},"        \"observation.base_ang_vel\": sport.GetBaseAngVel(),\n",[437,1776,1777],{"class":439,"line":530},[437,1778,1779],{},"    }\n",[437,1781,1782],{"class":439,"line":536},[437,1783,1784],{},"    with torch.no_grad():\n",[437,1786,1787],{"class":439,"line":542},[437,1788,1789],{},"        actions = policy.select_action(obs)         # (100, 15)\n",[437,1791,1792],{"class":439,"line":548},[437,1793,471],{"emptyLinePlaceholder":282},[437,1795,1796],{"class":439,"line":554},[437,1797,1798],{},"    # Apply the first 30 actions (1 second of behavior at 30 Hz),\n",[437,1800,1801],{"class":439,"line":560},[437,1802,1803],{},"    # then re-plan. This is the temporal ensembling trick from ACT.\n",[437,1805,1806],{"class":439,"line":1159},[437,1807,1808],{},"    for i in range(30):\n",[437,1810,1811],{"class":439,"line":1165},[437,1812,1813],{},"        cmd = actions[i]\n",[437,1815,1816],{"class":439,"line":1171},[437,1817,1818],{},"        sport.Move(*cmd[:3].cpu().numpy())         # velocity\n",[437,1820,1821],{"class":439,"line":1177},[437,1822,1823],{},"        apply_joint_targets(cmd[3:].cpu().numpy())\n",[437,1825,1826],{"class":439,"line":1183},[437,1827,1362],{},[16,1829,1830],{},"We use temporal ensembling because it's robust to single-frame\nnoise — if one frame is bad, the next 29 actions still come from\na coherent chunk.",[29,1832,1834],{"id":1833},"_6-results","6. Results",[16,1836,1837],{},"On the customer fleet, the ACT policy outperforms the built-in\nsport-mode controller on three metrics:",[589,1839,1840,1856],{},[592,1841,1842],{},[595,1843,1844,1847,1850,1853],{},[598,1845,1846],{},"Metric",[598,1848,1849],{},"Built-in",[598,1851,1852],{},"ACT (ours)",[598,1854,1855],{},"Improvement",[608,1857,1858,1872,1886],{},[595,1859,1860,1863,1866,1869],{},[613,1861,1862],{},"Threshold success (5 cm)",[613,1864,1865],{},"88%",[613,1867,1868],{},"99%",[613,1870,1871],{},"+11pp",[595,1873,1874,1877,1880,1883],{},[613,1875,1876],{},"Threshold success (12 cm)",[613,1878,1879],{},"22%",[613,1881,1882],{},"67%",[613,1884,1885],{},"+45pp",[595,1887,1888,1891,1893,1896],{},[613,1889,1890],{},"Recovery from push (10 N)",[613,1892,1501],{},[613,1894,1895],{},"91%",[613,1897,1898],{},"+50pp",[16,1900,1901],{},"The improvement is largest where the built-in controller fails:\nthe cases that need a learned policy to handle.",[29,1903,1905],{"id":1904},"_7-when-to-use-which","7. When to use which",[16,1907,1908],{},"Decision tree for locomotion policies on Unitree platforms:",[85,1910,1913],{"className":1911,"code":1912,"language":90},[88],"                ┌───────────────────────────────┐\n                │ What's the deployment terrain? │\n                └───────────────────────────────┘\n                              │\n              ┌───────────────┼───────────────┐\n              ▼               ▼               ▼\n          Flat ground    Mixed terrain    Rough terrain\n              │               │               │\n              ▼               ▼               ▼\n        Use built-in     Use sim-to-real    Use IL\n        sport mode       (Isaac Lab)       (LeRobot)\n                                          [this article]\n",[24,1914,1912],{"__ignoreMap":93},[16,1916,1917,1918,1921],{},"For most customer deployments, the answer is ",[19,1919,1920],{},"\"IL on rough\nterrain, built-in on flat ground, sim-to-real when we need a\nspecific behavior RL can express as a reward\"",".",[16,1923,1924,1925,1921],{},"If you want help scoping which approach fits your use case,\n",[256,1926,1927],{"href":258},"send us your mission profile",[816,1929,1930],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":93,"searchDepth":262,"depth":262,"links":1932},[1933,1934,1938,1942,1946,1947,1948],{"id":866,"depth":262,"text":867},{"id":881,"depth":262,"text":882,"children":1935},[1936,1937],{"id":364,"depth":271,"text":365},{"id":388,"depth":271,"text":389},{"id":1021,"depth":262,"text":1022,"children":1939},[1940,1941],{"id":1025,"depth":271,"text":1026},{"id":1411,"depth":271,"text":1412},{"id":1457,"depth":262,"text":1458,"children":1943},[1944,1945],{"id":1461,"depth":271,"text":1462},{"id":1550,"depth":271,"text":1551},{"id":1689,"depth":262,"text":1690},{"id":1833,"depth":262,"text":1834},{"id":1904,"depth":262,"text":1905},"2026-06-04",{},"\u002Fen\u002Fblog\u002Flerobot-meets-unitree-go2",{"title":849,"description":93},"lerobot-meets-unitree-go2","en\u002Fblog\u002Flerobot-meets-unitree-go2","Imitation learning is the right tool for quadruped locomotion over rough terrain. We share the workflow that goes from 50 teleoperated demonstrations to a deployed Go2 policy in 6 hours.",[1957,1958,1959,1960],"lerobot","imitation-learning","unitree-go2","teleop","forest","F8YvNlxIbq8GshlrqGutjtnVxP3pRwY88icsjQHzzQA",{"id":4,"title":5,"author":6,"authorBio":7,"authorRole":8,"body":1964,"category":278,"date":279,"description":93,"excerpt":280,"extension":281,"featured":282,"image":280,"meta":2131,"navigation":282,"path":284,"pinned":285,"readTime":286,"seo":2132,"slug":288,"stem":289,"summary":290,"tags":2133,"tone":295,"updated":296,"__hash__":297},{"type":10,"value":1965,"toc":2116},[1966,1974,1976,2002,2004,2006,2008,2013,2015,2017,2019,2021,2035,2037,2039,2041,2049,2051,2053,2055,2057,2062,2066,2068,2070,2072,2074,2076,2080,2082,2084,2086,2088,2092,2094,2096,2110,2112],[13,1967,1968],{},[16,1969,1970,22,1972,27],{},[19,1971,21],{},[24,1973,26],{},[29,1975,32],{"id":31},[34,1977,1978,1982,1986,1992,1996],{},[37,1979,1980,42],{},[19,1981,41],{},[37,1983,1984,48],{},[19,1985,47],{},[37,1987,1988,54,1990,58],{},[19,1989,53],{},[24,1991,57],{},[37,1993,1994,64],{},[19,1995,63],{},[37,1997,1998,70,2000,73],{},[19,1999,69],{},[24,2001,26],{},[16,2003,76],{},[29,2005,80],{"id":79},[16,2007,83],{},[85,2009,2011],{"className":2010,"code":89,"language":90},[88],[24,2012,89],{"__ignoreMap":93},[16,2014,96],{},[16,2016,99],{},[29,2018,103],{"id":102},[16,2020,106],{},[108,2022,2023,2027,2031],{},[37,2024,2025,115],{},[19,2026,114],{},[37,2028,2029,121],{},[19,2030,120],{},[37,2032,2033,127],{},[19,2034,126],{},[16,2036,130],{},[29,2038,134],{"id":133},[16,2040,137],{},[34,2042,2043,2045,2047],{},[37,2044,142],{},[37,2046,145],{},[37,2048,148],{},[16,2050,151],{},[29,2052,155],{"id":154},[16,2054,158],{},[160,2056,163],{"id":162},[85,2058,2060],{"className":2059,"code":167,"language":90},[88],[24,2061,167],{"__ignoreMap":93},[16,2063,172,2064,175],{},[24,2065,26],{},[160,2067,179],{"id":178},[16,2069,182],{},[160,2071,186],{"id":185},[16,2073,189],{},[160,2075,193],{"id":192},[16,2077,196,2078,200],{},[24,2079,199],{},[160,2081,204],{"id":203},[16,2083,207],{},[29,2085,211],{"id":210},[16,2087,214],{},[16,2089,217,2090,221],{},[19,2091,220],{},[29,2093,225],{"id":224},[16,2095,228],{},[34,2097,2098,2100,2102,2106,2108],{},[37,2099,233],{},[37,2101,236],{},[37,2103,239,2104,242],{},[24,2105,199],{},[37,2107,245],{},[37,2109,248],{},[16,2111,251],{},[16,2113,254,2114,260],{},[256,2115,259],{"href":258},{"title":93,"searchDepth":262,"depth":262,"links":2117},[2118,2119,2120,2121,2122,2129,2130],{"id":31,"depth":262,"text":32},{"id":79,"depth":262,"text":80},{"id":102,"depth":262,"text":103},{"id":133,"depth":262,"text":134},{"id":154,"depth":262,"text":155,"children":2123},[2124,2125,2126,2127,2128],{"id":162,"depth":271,"text":163},{"id":178,"depth":271,"text":179},{"id":185,"depth":271,"text":186},{"id":192,"depth":271,"text":193},{"id":203,"depth":271,"text":204},{"id":210,"depth":262,"text":211},{"id":224,"depth":262,"text":225},{},{"title":5,"description":93},[292,293,294,278],{"id":2135,"title":2136,"author":302,"authorBio":2137,"authorRole":304,"body":2138,"category":2459,"date":2460,"description":93,"excerpt":280,"extension":281,"featured":282,"image":280,"meta":2461,"navigation":282,"path":2462,"pinned":285,"readTime":486,"seo":2463,"slug":2464,"stem":2465,"summary":2466,"tags":2467,"tone":845,"updated":280,"__hash__":2471},"blog\u002Fen\u002Fblog\u002Freal-battery-life.md","Why your Unitree G1 battery lasts 2 hours, not 10","We measured these curves on customer hardware over three months. The numbers below are real; the methodology is open.",{"type":10,"value":2139,"toc":2451},[2140,2159,2163,2166,2186,2189,2192,2256,2260,2266,2269,2275,2278,2284,2288,2291,2296,2303,2314,2317,2324,2328,2331,2336,2339,2345,2350,2357,2362,2374,2378,2381,2386,2389,2394,2405,2410,2413,2418,2425,2430,2433,2437,2444],[13,2141,2142],{},[16,2143,2144,2146,2147,2150,2151,2154,2155,2158],{},[19,2145,21],{}," The \"5–10 hours\" battery claims on Unitree spec sheets\nare standby numbers, measured at zero joint motion, zero sensor\nprocessing, and zero communication. Under a realistic active load —\nwalking, manipulating, perceiving — the G1 runs ",[19,2148,2149],{},"~2 hours",", the\nGo2 runs ",[19,2152,2153],{},"~1–2 hours",", and the B2 runs ",[19,2156,2157],{},"~2–3 hours continuous\nload \u002F 4–6 hours patrol",". The gap isn't dishonesty; it's a\ndefinitional mismatch that costs customers real money when they\ndon't know.",[29,2160,2162],{"id":2161},"_1-the-measurement-setup","1. The measurement setup",[16,2164,2165],{},"We instrumented three customer robots over a 12-week period:",[34,2167,2168,2174,2180],{},[37,2169,2170,2173],{},[19,2171,2172],{},"G1"," (base, 23 DoF) with standard 13-string Li-ion pack",[37,2175,2176,2179],{},[19,2177,2178],{},"Go2"," (Air, 12 DoF) with the standard 8.6 Ah pack",[37,2181,2182,2185],{},[19,2183,2184],{},"B2"," (industrial, 12 DoF) with the long-range 22 Ah pack",[16,2187,2188],{},"Each robot had a current clamp on the main battery lead and a\nvoltage tap on the BMS sense line. Data was logged at 100 Hz to\nan on-board SD card and post-processed.",[16,2190,2191],{},"We tested five load profiles:",[589,2193,2194,2204],{},[592,2195,2196],{},[595,2197,2198,2201],{},[598,2199,2200],{},"Profile",[598,2202,2203],{},"Description",[608,2205,2206,2216,2226,2236,2246],{},[595,2207,2208,2213],{},[613,2209,2210],{},[19,2211,2212],{},"Standby",[613,2214,2215],{},"Robot on, motors idle, all sensors streaming",[595,2217,2218,2223],{},[613,2219,2220],{},[19,2221,2222],{},"Light patrol",[613,2224,2225],{},"Slow walk (0.5 m\u002Fs), no manipulation, LiDAR SLAM running",[595,2227,2228,2233],{},[613,2229,2230],{},[19,2231,2232],{},"Heavy patrol",[613,2234,2235],{},"Full speed (2.0 m\u002Fs for quadrupeds, 1.5 m\u002Fs for humanoid), SLAM + object detection",[595,2237,2238,2243],{},[613,2239,2240],{},[19,2241,2242],{},"Manipulation",[613,2244,2245],{},"G1 only — standing still, arm executing pick-and-place at 1 Hz",[595,2247,2248,2253],{},[613,2249,2250],{},[19,2251,2252],{},"Aggressive manipulation",[613,2254,2255],{},"G1 only — full body motion (walking + arm) under ROS 2 control",[29,2257,2259],{"id":2258},"_2-the-numbers","2. The numbers",[85,2261,2264],{"className":2262,"code":2263,"language":90},[88],"Standby power draw (all platforms):\n  G1:  28 W    Go2: 14 W    B2:  22 W\n\nActive patrol (typical customer workload):\n  G1: 110 W    Go2: 48 W    B2:  78 W\n\nAggressive manipulation:\n  G1: 165 W    Go2:  N\u002FA    B2:  N\u002FA\n",[24,2265,2263],{"__ignoreMap":93},[16,2267,2268],{},"Battery capacity, total \u002F usable:",[85,2270,2273],{"className":2271,"code":2272,"language":90},[88],"  G1:  0.96 kWh total, 0.86 kWh usable (BMS cutoff at 18 V)\n  Go2: 0.43 kWh total, 0.39 kWh usable\n  B2:  1.50 kWh total, 1.35 kWh usable\n",[24,2274,2272],{"__ignoreMap":93},[16,2276,2277],{},"Time to BMS cutoff (low-voltage shutdown):",[85,2279,2282],{"className":2280,"code":2281,"language":90},[88],"                 Standby    Patrol    Heavy patrol    Manipulation\n  G1             30 h        7.8 h     2.1 h          5.2 h\n  Go2            28 h        8.1 h     1.4 h          —\n  B2             61 h        17 h      4.6 h          —\n",[24,2283,2281],{"__ignoreMap":93},[29,2285,2287],{"id":2286},"_3-the-marketing-510-hours-claim","3. The \"marketing 5–10 hours\" claim",[16,2289,2290],{},"The manufacturer specs quote figures like:",[13,2292,2293],{},[16,2294,2295],{},"\"5–10 hours battery life\"\n— Unitree G1 product page, retrieved June 2026",[16,2297,2298,2299,2302],{},"These numbers are ",[19,2300,2301],{},"standby plus intermittent walking",". Specifically,\nthey assume a duty cycle of:",[34,2304,2305,2308,2311],{},[37,2306,2307],{},"80% standby (sensors on, motors idle)",[37,2309,2310],{},"20% light patrol (0.3 m\u002Fs, no manipulation, no perception)",[37,2312,2313],{},"Wi-Fi connected but not streaming high-bandwidth sensor data",[16,2315,2316],{},"If your operation is \"robot walks a patrol loop and watches the\nwarehouse with its cameras,\" you're closer to the marketing number.\nIf your operation is \"robot actively manipulates objects while\nwalking,\" you're 2.5× below the marketing number.",[16,2318,2319,2320,2323],{},"This is ",[19,2321,2322],{},"not"," Unitree being deceptive. It's the standard\n\"automotive MPG\" problem — manufacturers quote the best-case\nlaboratory cycle, customers experience the worst-case real cycle.\nThe fix isn't regulatory; it's transparency.",[29,2325,2327],{"id":2326},"_4-what-drives-the-gap","4. What drives the gap",[16,2329,2330],{},"Three things, in order of impact:",[16,2332,2333],{},[19,2334,2335],{},"4.1 Joint actuator power under load.",[16,2337,2338],{},"A stationary humanoid robot still draws 28 W (G1) just to hold\nitself against gravity. The joint actuators are position-controlled\nPD loops running at 1 kHz; even at \"zero\" velocity command, the\nloop is actively correcting against gravity sag.",[16,2340,2341,2342,1921],{},"When the robot walks, the swing-leg actuators spike to 200–300 W\nfor tens of milliseconds per stride. The average goes up because\nthe actuators are fighting momentum. Walking the G1 at 1.5 m\u002Fs\ncosts roughly ",[19,2343,2344],{},"5× the standby power",[16,2346,2347],{},[19,2348,2349],{},"4.2 Sensor processing.",[16,2351,2352,2353,2356],{},"LiDAR SLAM at 10 Hz is cheap (~3 W on a Jetson Orin). Adding\nRealSense RGB-D + YOLO object detection at 30 Hz costs another\n~8 W. Adding a VLA model (UnifoLM-VLA-0 running inference) costs\n~15 W. Customers running the full perception stack will see\n",[19,2354,2355],{},"20–30% less battery life"," than the same robot with LiDAR only.",[16,2358,2359],{},[19,2360,2361],{},"4.3 Communication.",[16,2363,2364,2365,2369,2370,2373],{},"The DDS multicast stream we discussed in ",[256,2366,2368],{"href":2367},"\u002Fblog\u002Funitree-sdk2-deep-dive","the SDK deep-dive","\nisn't a meaningful power draw on Wi-Fi, but it is on 5G cellular.\nA 5G modem streaming telemetry at 10 Hz costs 4–6 W continuously.\nA field-deployed G1 on 5G will see ",[19,2371,2372],{},"~10% less battery life","\nthan the same G1 on Wi-Fi.",[29,2375,2377],{"id":2376},"_5-what-you-can-do","5. What you can do",[16,2379,2380],{},"Practical mitigations, in order of ROI:",[16,2382,2383],{},[19,2384,2385],{},"5.1 Match the mission profile to the spec.",[16,2387,2388],{},"If you're buying a Go2 for warehouse patrol, accept that you'll\nget 8 hours of patrol under the spec. If you're buying a G1 for\nactive manipulation, plan on a 2-hour active window with\nopportunity charging between shifts.",[16,2390,2391],{},[19,2392,2393],{},"5.2 Reduce sensor load during low-activity periods.",[16,2395,2396,2397,2400,2401,2404],{},"We ship most customer deployments with a ",[24,2398,2399],{},"sensor-mode"," ROS parameter\nthat toggles between \"full perception\" and \"idle perception\" based\non motion. When the robot hasn't moved in 30 seconds, drop to LiDAR\nonly at 1 Hz. When motion resumes, ramp back up over 2 seconds.\nThis saves ",[19,2402,2403],{},"~15% of total mission power"," for typical mixed\nworkloads.",[16,2406,2407],{},[19,2408,2409],{},"5.3 Thermal management.",[16,2411,2412],{},"Lithium cells lose capacity below 10 °C and above 40 °C. A cold\nwarehouse (5 °C) will reduce effective capacity by ~15%. A hot\nloading dock (35 °C) will degrade cell longevity by 30% per year.\nIf your environment is at the edge, add a battery heater or active\ncooling — it's cheaper than replacing the pack every 18 months.",[16,2414,2415],{},[19,2416,2417],{},"5.4 Mission profile tuning.",[16,2419,2420,2421,2424],{},"Don't run the robot at \"sport mode\" continuously. Most customer\nmissions are well-served by ",[24,2422,2423],{},"Move(0.5, 0, 0)"," — slow, deliberate\nwalking. This cuts peak joint power by ~40% and average by ~25%.",[16,2426,2427],{},[19,2428,2429],{},"5.5 Battery pack upgrade.",[16,2431,2432],{},"The G1 accepts third-party packs at the same voltage but higher\ncapacity (we've tested packs up to 1.4 kWh). The downside is\nweight (the robot is already balanced for the stock pack) and\nwarranty. Don't do this without consulting us; we have a\nbalancing and thermal characterization for the common packs.",[29,2434,2436],{"id":2435},"_6-how-we-use-this-data","6. How we use this data",[16,2438,2439,2440,2443],{},"When we scope an integration engagement, we ask the customer for\ntheir mission profile up front. We then quote the expected battery\nlife ",[19,2441,2442],{},"at their profile",", not the marketing number. We've found\nthis avoids the most common source of customer disappointment:\n\"we bought a 5-hour robot and it's dying after 90 minutes.\"",[16,2445,2446,2447,2450],{},"If you're scoping a Unitree purchase, ",[256,2448,2449],{"href":258},"send us your mission\nprofile"," and we'll give you a real battery estimate.",{"title":93,"searchDepth":262,"depth":262,"links":2452},[2453,2454,2455,2456,2457,2458],{"id":2161,"depth":262,"text":2162},{"id":2258,"depth":262,"text":2259},{"id":2286,"depth":262,"text":2287},{"id":2326,"depth":262,"text":2327},{"id":2376,"depth":262,"text":2377},{"id":2435,"depth":262,"text":2436},"research","2026-06-12",{},"\u002Fen\u002Fblog\u002Freal-battery-life",{"title":2136,"description":93},"real-battery-life","en\u002Fblog\u002Freal-battery-life","We measured the G1, Go2, and B2 battery curves under realistic loads. The marketing numbers are standby. Here's what the actual duty cycle looks like.",[2468,2469,2470],"battery","hardware","measurement","9mmpXsRB9BjccJd0ah4C0_QE28MtFvXRXCjvwU9rYhA",{"id":2473,"title":2474,"author":302,"authorBio":2475,"authorRole":304,"body":2476,"category":3045,"date":3046,"description":93,"excerpt":280,"extension":281,"featured":282,"image":280,"meta":3047,"navigation":282,"path":3048,"pinned":282,"readTime":513,"seo":3049,"slug":3050,"stem":3051,"summary":3052,"tags":3053,"tone":1961,"updated":3057,"__hash__":3058},"blog\u002Fen\u002Fblog\u002Funitree-sdk2-deep-dive.md","The unitree_sdk2 in 2026: A practitioner's map of Unitree's public SDK","The qtvue engineering team works on Unitree integration projects every week. We wrote this guide because the official SDK docs are fragmented and Chinese-first, and we wanted a single English reference we could hand to new engineers.",{"type":10,"value":2477,"toc":3034},[2478,2488,2492,2495,2545,2548,2576,2580,2591,2594,2612,2675,2679,2682,2688,2702,2706,2717,2720,2774,2814,2818,2821,2846,2866,2884,2898,2905,2918,2932,2936,2939,2975,2985,2989,2992,3029,3032],[13,2479,2480],{},[16,2481,2482,2484,2485,2487],{},[19,2483,21],{}," The unitree_sdk2 is the official C++ and Python SDK across the\nfull Unitree range (Go2, B2, G1, G1-D, R1, H1, H2, Arms). It speaks\nCyclone DDS over UDP multicast on a local LAN, exposes seven control\nsurfaces (low-level joint, high-level sport, arm, hand, lidar, audio,\nvideo), and ships with ",[24,2486,346],{}," for the ROS 2 ecosystem. The\nofficial docs are fragmented — this article is the map we wish existed.",[29,2489,2491],{"id":2490},"_1-where-the-sdk-lives","1. Where the SDK lives",[16,2493,2494],{},"The public SDK is split across four GitHub repos maintained by Unitree\nand Unitree-affiliated contributors:",[589,2496,2497,2507],{},[592,2498,2499],{},[595,2500,2501,2504],{},[598,2502,2503],{},"Repo",[598,2505,2506],{},"What it ships",[608,2508,2509,2518,2527,2536],{},[595,2510,2511,2515],{},[613,2512,2513],{},[24,2514,862],{},[613,2516,2517],{},"C++ core (builds on Ubuntu 20.04 \u002F 22.04)",[595,2519,2520,2524],{},[613,2521,2522],{},[24,2523,907],{},[613,2525,2526],{},"Python bindings (CPython 3.8+)",[595,2528,2529,2533],{},[613,2530,2531],{},[24,2532,346],{},[613,2534,2535],{},"ROS 2 Humble \u002F Jazzy DDS-XRCE bridges",[595,2537,2538,2542],{},[613,2539,2540],{},[24,2541,316],{},[613,2543,2544],{},"Isaac Lab environment files + URDF",[16,2546,2547],{},"All four are mirrored on a single internal CI we maintain; in practice,\nwe clone them as a sparse checkout into our customer repos because the\nrelease tags drift between the C++ and Python repos.",[13,2549,2550],{},[16,2551,2552,2555,2556,2559,2560,2563,2564,2567,2568,2571,2572,2575],{},[19,2553,2554],{},"Gotcha #1."," The C++ repo is tagged ",[24,2557,2558],{},"vX.Y"," but the Python bindings\nare tagged independently. A ",[24,2561,2562],{},"v2.4.0"," C++ tag may ship with ",[24,2565,2566],{},"v2.3.1","\nPython bindings. Pin both tags in your ",[24,2569,2570],{},"requirements.txt"," \u002F\n",[24,2573,2574],{},"CMakeLists.txt"," and treat them as a coupled pair.",[29,2577,2579],{"id":2578},"_2-transport-cyclone-dds-over-udp-multicast","2. Transport: Cyclone DDS over UDP multicast",[16,2581,2582,2583,2586,2587,2590],{},"The SDK assumes a direct Ethernet (or Wi-Fi) connection between your\ncontrol host and the robot's onboard compute. It uses ",[19,2584,2585],{},"Cyclone DDS","\nwith UDP multicast on ",[24,2588,2589],{},"239.255.0.1:8888"," by default. Every state\nstream (joint states, IMU, LiDAR, battery) and every command stream\n(joint commands, locomotion goals, arm trajectory points) goes over\nthis one multicast group.",[16,2592,2593],{},"In production this has two practical consequences:",[108,2595,2596,2606],{},[37,2597,2598,2601,2602,2605],{},[19,2599,2600],{},"You need a clean Layer 2."," Multicast doesn't survive routers,\nand many corporate switches drop multicast by default. We tell\ncustomers to put the robot on its own VLAN with the control host,\nwith the switch port configured for ",[24,2603,2604],{},"multicast-flood"," or with\nIGMP snooping properly enabled.",[37,2607,2608,2611],{},[19,2609,2610],{},"You can't run two SDK instances on the same VLAN."," Two hosts\nsending locomotion commands at the same time will fight over the\nrobot. This is the most common customer support issue we see.\nWe always run the SDK on a dedicated NUC or Jetson, not on a shared\nworkstation.",[85,2613,2615],{"className":431,"code":2614,"language":433,"meta":93,"style":93},"# Explicit DDS configuration — pin the interface, force IPv4, set QoS\nimport cyclonedds\nfrom cyclonedds.domain import DomainParticipant\nfrom cyclonedds.core import Qos, Policy\n\nqos = Qos(\n    Policy.Reliability.BestEffort,\n    Policy.History.KeepLast(10),\n    Policy.Durability.Volatile,\n)\n# Tell Cyclone DDS which interface to use on multi-NIC hosts\ncyclonedds.config_set(\"Local.\u002FNetwork\u002FInterfaces\", \"eth0\")\n",[24,2616,2617,2622,2627,2632,2637,2641,2646,2651,2656,2661,2665,2670],{"__ignoreMap":93},[437,2618,2619],{"class":439,"line":440},[437,2620,2621],{},"# Explicit DDS configuration — pin the interface, force IPv4, set QoS\n",[437,2623,2624],{"class":439,"line":262},[437,2625,2626],{},"import cyclonedds\n",[437,2628,2629],{"class":439,"line":271},[437,2630,2631],{},"from cyclonedds.domain import DomainParticipant\n",[437,2633,2634],{"class":439,"line":456},[437,2635,2636],{},"from cyclonedds.core import Qos, Policy\n",[437,2638,2639],{"class":439,"line":462},[437,2640,471],{"emptyLinePlaceholder":282},[437,2642,2643],{"class":439,"line":468},[437,2644,2645],{},"qos = Qos(\n",[437,2647,2648],{"class":439,"line":474},[437,2649,2650],{},"    Policy.Reliability.BestEffort,\n",[437,2652,2653],{"class":439,"line":480},[437,2654,2655],{},"    Policy.History.KeepLast(10),\n",[437,2657,2658],{"class":439,"line":486},[437,2659,2660],{},"    Policy.Durability.Volatile,\n",[437,2662,2663],{"class":439,"line":491},[437,2664,1660],{},[437,2666,2667],{"class":439,"line":286},[437,2668,2669],{},"# Tell Cyclone DDS which interface to use on multi-NIC hosts\n",[437,2671,2672],{"class":439,"line":502},[437,2673,2674],{},"cyclonedds.config_set(\"Local.\u002FNetwork\u002FInterfaces\", \"eth0\")\n",[29,2676,2678],{"id":2677},"_3-the-seven-control-surfaces","3. The seven control surfaces",[16,2680,2681],{},"When we say \"the SDK\" we mean a federated set of modules. Each one\ntalks to a separate DDS topic and exposes its own message types:",[85,2683,2686],{"className":2684,"code":2685,"language":90},[88],"┌──────────────────────────────────────────────────────┐\n│                  unitree_sdk2 surfaces                │\n├──────────┬───────────────┬───────────────────────────┤\n│ LowState │ \u003Csport>       │ SportClient (high-level    │\n│ LowCmd   │ \u003Chand>        │ locomotion, arm, hand,    │\n│          │ \u003Carm>         │ LiDAR, audio, video)      │\n├──────────┼───────────────┼───────────────────────────┤\n│ MotorCmd │ \u003Clowlevel>    │ Direct joint torque \u002F     │\n│ MotorState│              │ position \u002F velocity PD    │\n├──────────┼───────────────┼───────────────────────────┤\n│ ArmCommand│ \u003Carm_sdk>    │ 6+ DoF arm trajectory     │\n│          │              │ (g1, h1, z1)              │\n├──────────┼───────────────┼───────────────────────────┤\n│ LidarScan│ \u003Clidar>       │ 4D LiDAR point cloud      │\n├──────────┼───────────────┼───────────────────────────┤\n│ AudioData│ \u003Caudio>      │ Microphone array +        │\n│          │              │ speaker playback          │\n├──────────┼───────────────┼───────────────────────────┤\n│ VideoFrame│\u003Cvideohub>    │ RGB-D camera stream        │\n└──────────┴───────────────┴───────────────────────────┘\n",[24,2687,2685],{"__ignoreMap":93},[16,2689,700,2690,2693,2694,2697,2698,2701],{},[19,2691,2692],{},"most common mistake"," is mixing the high-level ",[24,2695,2696],{},"SportClient","\ncalls with low-level ",[24,2699,2700],{},"MotorCmd"," writes in the same process. The\nSDK doesn't enforce a single-writer invariant — both work — but\nthey fight at runtime and you'll see the robot twitch erratically.\nWe always isolate them: one process owns low-level joints (or none\ndoes), one process owns high-level goals, one process owns the arm.",[29,2703,2705],{"id":2704},"_4-the-python-bindings","4. The Python bindings",[16,2707,2708,2709,2712,2713,2716],{},"The Python bindings wrap the C++ core with ",[24,2710,2711],{},"pybind11",". They expose\nthe same message types as dataclasses and the same clients as\nhigh-level Python classes. The trade-off is ",[19,2714,2715],{},"GC pressure"," — every\nstate callback allocates a new message, and Python's GC can stall\nthe 200 Hz control loop if you're not careful.",[16,2718,2719],{},"Two patterns that work:",[85,2721,2723],{"className":431,"code":2722,"language":433,"meta":93,"style":93},"# 1. Use the pre-allocated read buffer for hot paths\nfrom unitree_sdk2py.core.channel import ChannelSubscriber\nfrom unitree_sdk2py.idl.unitree_hg import LowState_\n\nsub = ChannelSubscriber(\"rt\u002Flowstate\", LowState_)\nsub.Init()\nwhile not shutdown_event.is_set():\n    msg = sub.Read()       # reuses an internal buffer\n    process(msg)            # no per-frame allocation\n    time.sleep(0.005)       # 200 Hz\n",[24,2724,2725,2730,2735,2740,2744,2749,2754,2759,2764,2769],{"__ignoreMap":93},[437,2726,2727],{"class":439,"line":440},[437,2728,2729],{},"# 1. Use the pre-allocated read buffer for hot paths\n",[437,2731,2732],{"class":439,"line":262},[437,2733,2734],{},"from unitree_sdk2py.core.channel import ChannelSubscriber\n",[437,2736,2737],{"class":439,"line":271},[437,2738,2739],{},"from unitree_sdk2py.idl.unitree_hg import LowState_\n",[437,2741,2742],{"class":439,"line":456},[437,2743,471],{"emptyLinePlaceholder":282},[437,2745,2746],{"class":439,"line":462},[437,2747,2748],{},"sub = ChannelSubscriber(\"rt\u002Flowstate\", LowState_)\n",[437,2750,2751],{"class":439,"line":468},[437,2752,2753],{},"sub.Init()\n",[437,2755,2756],{"class":439,"line":474},[437,2757,2758],{},"while not shutdown_event.is_set():\n",[437,2760,2761],{"class":439,"line":480},[437,2762,2763],{},"    msg = sub.Read()       # reuses an internal buffer\n",[437,2765,2766],{"class":439,"line":486},[437,2767,2768],{},"    process(msg)            # no per-frame allocation\n",[437,2770,2771],{"class":439,"line":491},[437,2772,2773],{},"    time.sleep(0.005)       # 200 Hz\n",[85,2775,2777],{"className":431,"code":2776,"language":433,"meta":93,"style":93},"# 2. For non-hot paths (telemetry), batch into lists\nstates = []\ndef cb(msg: LowState_):\n    states.append(msg.tick)\n    if len(states) >= 1000:\n        flush(states)        # every ~5s at 200 Hz\n        states.clear()\n",[24,2778,2779,2784,2789,2794,2799,2804,2809],{"__ignoreMap":93},[437,2780,2781],{"class":439,"line":440},[437,2782,2783],{},"# 2. For non-hot paths (telemetry), batch into lists\n",[437,2785,2786],{"class":439,"line":262},[437,2787,2788],{},"states = []\n",[437,2790,2791],{"class":439,"line":271},[437,2792,2793],{},"def cb(msg: LowState_):\n",[437,2795,2796],{"class":439,"line":456},[437,2797,2798],{},"    states.append(msg.tick)\n",[437,2800,2801],{"class":439,"line":462},[437,2802,2803],{},"    if len(states) >= 1000:\n",[437,2805,2806],{"class":439,"line":468},[437,2807,2808],{},"        flush(states)        # every ~5s at 200 Hz\n",[437,2810,2811],{"class":439,"line":474},[437,2812,2813],{},"        states.clear()\n",[29,2815,2817],{"id":2816},"_5-the-four-footguns","5. The four footguns",[16,2819,2820],{},"These are the bugs we hit on real engagements. Each one has cost us\nor a customer at least a week of debugging.",[16,2822,2823,2830,2831,2834,2835,2839,2840,2842,2843,1921],{},[19,2824,2825,2826,2829],{},"Footgun 1 — ",[24,2827,2828],{},"Walk(true)"," doesn't fully enable sport mode.","\nYou need to call ",[24,2832,2833],{},"BalanceStand(true)"," first, wait for the robot to\nsettle (~0.5s), ",[2836,2837,2838],"em",{},"then"," call ",[24,2841,2828],{},". Skip the balance step and\nthe robot tips over on its first ",[24,2844,2845],{},"Move()",[16,2847,2848,2855,2857,2858,2861,2862,2865],{},[19,2849,2850,2851,2854],{},"Footgun 2 — ",[24,2852,2853],{},"StopMove()"," doesn't actually stop.",[24,2856,2853],{}," issues a single zero-velocity command. The high-level\ncontroller interpolates to a stop over ~0.3s. If you're on a real-\ntime control loop, you need to also send ",[24,2859,2860],{},"Move(0, 0, 0)"," repeatedly\nuntil the robot is stationary. Or just use ",[24,2863,2864],{},"SportClient.Damp()"," —\nthe SDK's emergency damping state.",[16,2867,2868,2871,2872,2875,2876,2883],{},[19,2869,2870],{},"Footgun 3 — Joint index mismatch between Go2 and G1.","\nGo2 has 12 joints in a quadruped arrangement (FR\u002FFL\u002FHR\u002FHL × 3 each).\nG1 has 23–43 joints in a humanoid arrangement. The ",[24,2873,2874],{},"LowCmd"," message\nflattens joints into a single array; the index ordering is documented\nper-platform. There is ",[19,2877,2878,2879,2882],{},"no shared ",[24,2880,2881],{},"kJointNames"," constant"," —\ncopy the order from your platform's URDF.",[16,2885,2886,2889,2890,2893,2894,2897],{},[19,2887,2888],{},"Footgun 4 — Multicast TTL leaks.","\nThe default DDS config uses TTL=1. On a multi-switch setup this is\nfine. But if you ever route traffic through a Linux bridge with\n",[24,2891,2892],{},"brctl",", the bridge will reset the TTL on forwarding and you'll see\nthe multicast loop back. Symptom: the control host sees its own\ncommands. Fix: set ",[24,2895,2896],{},"TTL=64"," in the DDS QoS or use a separate VLAN.",[29,2899,2901,2902,2904],{"id":2900},"_6-ros-2-the-unitree_ros2-layer","6. ROS 2 — the ",[24,2903,346],{}," layer",[16,2906,2907,2909,2910,2913,2914,2917],{},[24,2908,346],{}," wraps the SDK with proper ROS 2 topics and TF trees.\nFor most customers, this is the right entry point — you get RViz,\n",[24,2911,2912],{},"ros2 bag",", ",[24,2915,2916],{},"ros2 topic echo",", and the entire ROS 2 ecosystem for\nfree. The trade-off is one extra hop of latency (~2 ms) and the\nneed to build a Cyclone DDS \u002F Fast DDS bridge correctly.",[16,2919,2920,2921,2924,2925,2928,2929,2931],{},"We have a working ",[24,2922,2923],{},"ros2 humble"," setup with ",[24,2926,2927],{},"cyclonedds"," configured\nthat most engagements start from. The ",[24,2930,346],{}," repo includes\nexample launch files for each platform.",[29,2933,2935],{"id":2934},"_7-what-we-ship","7. What we ship",[16,2937,2938],{},"For customer engagements, we wrap the SDK with our own safety layer:",[108,2940,2941,2950,2960,2966],{},[37,2942,2943,2946,2947,1921],{},[19,2944,2945],{},"Heartbeat watchdog"," — if the control host misses three\nconsecutive state updates, the robot goes to ",[24,2948,2949],{},"Damp()",[37,2951,2952,2955,2956,2959],{},[19,2953,2954],{},"Velocity limiter"," — caps ",[24,2957,2958],{},"Move(x, y, yaw)"," to the platform's\npublished max × 0.8.",[37,2961,2962,2965],{},[19,2963,2964],{},"Geofence"," — uses LiDAR SLAM pose to enforce a polygon. If the\nrobot leaves the polygon, it stops and awaits human approval.",[37,2967,2968,2971,2972,2974],{},[19,2969,2970],{},"Emergency stop"," — physical E-stop button wired to a watchdog\nGPIO that calls ",[24,2973,2949],{}," directly, bypassing the SDK.",[16,2976,2977,2978,2984],{},"We open-source these patterns at\n",[256,2979,2983],{"href":2980,"rel":2981},"https:\u002F\u002Fgithub.com\u002FUDGOK\u002Fqtvue",[2982],"nofollow","github.com\u002FUDGOK\u002Fqtvue"," under MIT.",[29,2986,2988],{"id":2987},"where-to-start","Where to start",[16,2990,2991],{},"If you're picking up the SDK for the first time:",[108,2993,2994,3004,3013,3019],{},[37,2995,2996,2997,2999,3000,3003],{},"Clone ",[24,2998,907],{}," and run the ",[24,3001,3002],{},"example\u002Fsport_client"," example\nwith your Go2 on the bench.",[37,3005,3006,3007,3009,3010,1921],{},"Switch to ",[24,3008,346],{}," and ",[24,3011,3012],{},"ros2 topic echo \u002Flowstate",[37,3014,3015,3016,3018],{},"Pick a target (autonomous patrol? manipulation? imitation\nlearning?) and read the matching ",[24,3017,316],{}," env.",[37,3020,3021,3022,3025,3026,1921],{},"Reach out if you're stuck — ",[24,3023,3024],{},"hello@qtvue.com"," or our ",[256,3027,3028],{"href":258},"intake\nform",[16,3030,3031],{},"The SDK is good. It's not perfect, and the docs are sparse. But\nonce you understand the seven surfaces and the four footguns, you\ncan ship real work on top of it.",[816,3033,818],{},{"title":93,"searchDepth":262,"depth":262,"links":3035},[3036,3037,3038,3039,3040,3041,3043,3044],{"id":2490,"depth":262,"text":2491},{"id":2578,"depth":262,"text":2579},{"id":2677,"depth":262,"text":2678},{"id":2704,"depth":262,"text":2705},{"id":2816,"depth":262,"text":2817},{"id":2900,"depth":262,"text":3042},"6. ROS 2 — the unitree_ros2 layer",{"id":2934,"depth":262,"text":2935},{"id":2987,"depth":262,"text":2988},"engineering","2026-06-18",{},"\u002Fen\u002Fblog\u002Funitree-sdk2-deep-dive",{"title":2474,"description":93},"unitree-sdk2-deep-dive","en\u002Fblog\u002Funitree-sdk2-deep-dive","Where the SDK actually lives, what's in it, what isn't, and the seven control surfaces you need to know before you ship code.",[862,3054,3055,3056],"ros2","dds","sdk","2026-06-22","fdqsensQN9AnOXiwhXovgWVD2hOTGt_Y15Cs0vsQm0o",1782174236064]