Skip to content

Commit eef226f

Browse files
pengpengclaude
andcommitted
fix(service): take --entity, name a missing target, and say the right form for the usual wrong guesses
With 0.0.4 an Agent turned a light yellow in three steps, then needed five to turn it off: --entity_id=..., --entity-id=..., hass-cli light.turn_off and a call with no target (a bare HTTP 400) all failed first. - service call takes --entity (repeatable or comma-separated), and reads --entity_id / --entity-id as it. - A call with no entity, device, area, floor, label or target fails locally when the live service catalog says the service takes a target, naming the flag and how to find the entity. - An unknown flag on service call names the three ways to pass a target and data; a domain.service used as a command names service call; other unknown commands keep their close matches. - Errors print in full: FriendlyMessage printed only the inner HTTP error, so the hint 0.0.4 added to a 400 never reached the terminal. - ha-lighting and ha-shared quick control use --entity. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1b3f509 commit eef226f

8 files changed

Lines changed: 104 additions & 15 deletions

File tree

‎cmd/root.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
package cmd
33

44
import (
5+
"fmt"
6+
"strings"
7+
58
"github.com/beclab/hass-cli/internal/cmdutil"
69
"github.com/spf13/cobra"
710
)
@@ -28,6 +31,21 @@ func NewRootCommand() *cobra.Command {
2831
SilenceUsage: true,
2932
SilenceErrors: true,
3033
Version: version,
34+
// A service name used as a command (hass-cli light.turn_off ...) is the
35+
// commonest wrong guess; say what the right command is.
36+
Args: cobra.ArbitraryArgs,
37+
RunE: func(cmd *cobra.Command, args []string) error {
38+
if len(args) == 0 {
39+
return cmd.Help()
40+
}
41+
if strings.Count(args[0], ".") == 1 && !strings.HasPrefix(args[0], "-") {
42+
return fmt.Errorf("unknown command %q for \"hass-cli\"; to call that service: hass-cli service call %s --entity <entity_id>", args[0], args[0])
43+
}
44+
if near := cmd.SuggestionsFor(args[0]); len(near) > 0 {
45+
return fmt.Errorf("unknown command %q for \"hass-cli\"; did you mean %s?", args[0], strings.Join(near, ", "))
46+
}
47+
return fmt.Errorf("unknown command %q for \"hass-cli\"; run hass-cli --help for the commands", args[0])
48+
},
3149
}
3250

3351
pf := root.PersistentFlags()

‎cmd/service.go‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/beclab/hass-cli/internal/catalog"
1010
"github.com/beclab/hass-cli/internal/cmdutil"
1111
"github.com/spf13/cobra"
12+
"github.com/spf13/pflag"
1213
)
1314

1415
func newServiceCmd(f *cmdutil.Factory) *cobra.Command {
@@ -17,7 +18,8 @@ func newServiceCmd(f *cmdutil.Factory) *cobra.Command {
1718
Short: "List and call services",
1819
Example: ` hass-cli service list
1920
hass-cli service describe light.turn_on
20-
hass-cli service call light.turn_on --arguments entity_id=light.kitchen`,
21+
hass-cli service call light.turn_on --arguments entity_id=light.kitchen
22+
hass-cli service call light.turn_off --entity light.kitchen`,
2123
}
2224

2325
cmd.AddCommand(&cobra.Command{
@@ -48,7 +50,7 @@ func newServiceCmd(f *cmdutil.Factory) *cobra.Command {
4850
})
4951

5052
var dataJSON string
51-
var arguments []string
53+
var arguments, entities []string
5254
callCmd := &cobra.Command{
5355
Use: "call <domain.service>",
5456
Short: "Call a service, e.g. light.turn_on --arguments entity_id=light.kitchen",
@@ -74,6 +76,9 @@ func newServiceCmd(f *cmdutil.Factory) *cobra.Command {
7476
data[strings.TrimSpace(k)] = coerceValue(strings.TrimSpace(v))
7577
}
7678
}
79+
if len(entities) > 0 {
80+
data["entity_id"] = strings.Join(entities, ",")
81+
}
7782
c, err := f.Client()
7883
if err != nil {
7984
return err
@@ -82,6 +87,9 @@ func newServiceCmd(f *cmdutil.Factory) *cobra.Command {
8287
if err := checkEntities(cmd.Context(), c, entityIDsIn(data)); err != nil {
8388
return err
8489
}
90+
if err := checkTarget(cmd.Context(), c, domain, service, data); err != nil {
91+
return err
92+
}
8593
raw, err := c.CallService(cmd.Context(), domain, service, data)
8694
if err != nil {
8795
return explainCallError(err, args[0])
@@ -91,6 +99,18 @@ func newServiceCmd(f *cmdutil.Factory) *cobra.Command {
9199
}
92100
callCmd.Flags().StringVar(&dataJSON, "data", "", "Service data as a JSON object (or @file.json)")
93101
callCmd.Flags().StringArrayVar(&arguments, "arguments", nil, "key=value pairs (repeatable; one flag may also hold several, comma-separated). Values may be numbers, true/false or JSON lists")
102+
callCmd.Flags().StringSliceVar(&entities, "entity", nil, "Target entity id(s), repeatable or comma-separated; the same as --arguments entity_id=...")
103+
// --entity_id and --entity-id are what an agent guesses first; take them for --entity.
104+
callCmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
105+
switch name {
106+
case "entity_id", "entity-id", "entities", "entity_ids", "entity-ids":
107+
name = "entity"
108+
}
109+
return pflag.NormalizedName(name)
110+
})
111+
callCmd.SetFlagErrorFunc(func(c *cobra.Command, err error) error {
112+
return fmt.Errorf("%w\nservice call takes the target and data as: --entity light.kitchen, --arguments key=value, or --data '{\"entity_id\":\"light.kitchen\",...}'", err)
113+
})
94114
cmd.AddCommand(callCmd)
95115

96116
cmd.AddCommand(&cobra.Command{

‎cmd/service_args.go‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"sort"
1010
"strings"
1111

12+
"github.com/beclab/hass-cli/internal/catalog"
1213
"github.com/beclab/hass-cli/internal/client"
1314
)
1415

@@ -174,3 +175,30 @@ func explainCallError(err error, service string) error {
174175
}
175176
return err
176177
}
178+
179+
// targetKeys are the ways a service call can name what it acts on.
180+
var targetKeys = []string{"entity_id", "device_id", "area_id", "floor_id", "label_id", "target"}
181+
182+
// checkTarget fails before the call when the service acts on targets and the
183+
// data names none: Home Assistant answers that with a bare "400: Bad Request".
184+
// Whether a service takes a target comes from the live service catalog.
185+
func checkTarget(ctx context.Context, c *client.Client, domain, service string, data map[string]any) error {
186+
for _, key := range targetKeys {
187+
if _, ok := data[key]; ok {
188+
return nil
189+
}
190+
}
191+
raw, err := c.Services(ctx)
192+
if err != nil {
193+
return nil
194+
}
195+
cat, err := catalog.Parse(raw)
196+
if err != nil {
197+
return nil
198+
}
199+
svc, ok := cat.Lookup(domain, service)
200+
if !ok || len(svc.Target) == 0 || string(svc.Target) == "null" {
201+
return nil
202+
}
203+
return fmt.Errorf("%s.%s needs a target: add --entity %s.<object_id> (find it with: hass-cli state list --domain %s --search <name>)", domain, service, domain, domain)
204+
}

‎internal/client/errors.go‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,17 @@ func FriendlyMessage(err error) string {
1818

1919
var httpErr *HTTPError
2020
if errors.As(err, &httpErr) {
21+
// err, not httpErr: a command may have wrapped the HTTP error with what to do
22+
// next, and printing only the inner error dropped that.
2123
switch {
2224
case httpErr.Status == 401 || httpErr.Status == 403:
23-
return httpErr.Error() + " (token rejected or lacks permission; check HASS_TOKEN is a valid admin long-lived token)"
25+
return err.Error() + " (token rejected or lacks permission; check HASS_TOKEN is a valid admin long-lived token)"
2426
case httpErr.Status == 404:
25-
return httpErr.Error() + " (not found; check the path or resource exists)"
27+
return err.Error() + " (not found; check the path or resource exists)"
2628
case httpErr.Status >= 500:
27-
return httpErr.Error() + " (Home Assistant internal error; check the HA logs)"
29+
return err.Error() + " (Home Assistant internal error; check the HA logs)"
2830
default:
29-
return httpErr.Error()
31+
return err.Error()
3032
}
3133
}
3234

‎internal/client/errors_test.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package client
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestFriendlyMessageKeepsWhatACommandAdded(t *testing.T) {
10+
err := fmt.Errorf("%w\nuse --data for lists", &HTTPError{Status: 400, Body: "400: Bad Request"})
11+
if msg := FriendlyMessage(err); !strings.Contains(msg, "use --data for lists") || !strings.Contains(msg, "HTTP 400") {
12+
t.Fatalf("FriendlyMessage dropped the hint: %q", msg)
13+
}
14+
}

‎skills/ha-lighting/SKILL.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: ha-lighting
3-
version: 0.3.1
3+
version: 0.3.2
44
description: "Turn lights, switches and fans on or off, dim them, set colour or colour temperature, set fan speed. The skill for 'turn on the light' or 'turn off the fan'."
55
metadata:
66
requires:
@@ -22,10 +22,10 @@ skills, run `service describe`, or check the state afterwards.
2222
hass-cli state list --domain light --search kitchen
2323

2424
# 2. Call the service on the entity_id from step 1
25-
hass-cli service call light.turn_on --arguments entity_id=light.kitchen
26-
hass-cli service call light.turn_off --arguments entity_id=light.kitchen
27-
hass-cli service call switch.turn_off --arguments entity_id=switch.porch
28-
hass-cli service call fan.turn_on --arguments entity_id=fan.bedroom
25+
hass-cli service call light.turn_on --entity light.kitchen
26+
hass-cli service call light.turn_off --entity light.kitchen
27+
hass-cli service call switch.turn_off --entity switch.porch
28+
hass-cli service call fan.turn_on --entity fan.bedroom
2929

3030
# Colour, brightness, warmth: one light.turn_on, which also switches it on
3131
hass-cli service call light.turn_on --data '{"entity_id":"light.kitchen","color_name":"yellow"}'

‎skills/ha-shared/SKILL.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: ha-shared
3-
version: 0.3.1
3+
version: 0.3.2
44
description: "Connecting hass-cli to Home Assistant (profile or token), output formats, raw REST and WebSocket access, and auth-error recovery. Load when a command cannot connect or authenticate; device control is in the domain skills."
55
metadata:
66
requires:
@@ -26,11 +26,11 @@ hass-cli state list --domain light --search kitchen
2626
hass-cli state list --search 客厅
2727

2828
# 2. Call the service on the entity_id from step 1
29-
hass-cli service call light.turn_on --arguments entity_id=light.kitchen
29+
hass-cli service call light.turn_on --entity light.kitchen
3030
hass-cli service call light.turn_on --data '{"entity_id":"light.kitchen","brightness_pct":30}'
31-
hass-cli service call switch.turn_off --arguments entity_id=switch.porch
31+
hass-cli service call switch.turn_off --entity switch.porch
3232
hass-cli service call climate.set_temperature --data '{"entity_id":"climate.living","temperature":21}'
33-
hass-cli service call cover.close_cover --arguments entity_id=cover.bedroom
33+
hass-cli service call cover.close_cover --entity cover.bedroom
3434
```
3535

3636
- The call prints the entities it changed: that is the confirmation. `No entity

‎smoke_test.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ func mockHAWith(t *testing.T, components []string) *httptest.Server {
4545
"turn_on": map[string]any{
4646
"name": "Turn on",
4747
"description": "Turn a light on",
48+
"target": map[string]any{"entity": []any{map[string]any{"domain": []string{"light"}}}},
4849
"fields": map[string]any{
4950
"brightness_pct": map[string]any{"description": "Brightness %"},
5051
"advanced_fields": map[string]any{"collapsed": true, "fields": map[string]any{"color_name": map[string]any{"example": "red"}}},
@@ -261,6 +262,8 @@ func TestSmoke(t *testing.T) {
261262
{"service-call-unchanged", []string{"service", "call", "light.turn_on", "--arguments", "entity_id=light.living_room"}, "No entity changed state"},
262263
{"state-list-yaml", []string{"-o", "yaml", "state", "list"}, "entity_id: light.kitchen"},
263264
{"state-list-ndjson", []string{"-o", "ndjson", "state", "list"}, "{\"entity_id\":\"sun.sun\""},
265+
{"service-call-entity_id-flag", []string{"service", "call", "light.turn_on", "--entity_id=light.kitchen"}, "light.kitchen"},
266+
{"service-call-entity-id-flag", []string{"service", "call", "light.turn_on", "--entity-id", "light.kitchen"}, "light.kitchen"},
264267
{"service-call-comma-pairs", []string{"service", "call", "light.turn_on", "--arguments", "entity_id=light.kitchen,brightness_pct=40"}, "light.kitchen"},
265268
{"service-describe-table", []string{"service", "describe", "light.turn_on"}, "brightness_pct"},
266269
{"service-describe-section-fields", []string{"service", "describe", "light.turn_on"}, "color_name"},
@@ -414,6 +417,10 @@ func TestServiceCallEntityChecks(t *testing.T) {
414417
cases := map[string][]string{
415418
"did you mean light.kitchen (Kitchen Ceiling)": {"service", "call", "light.turn_on", "--arguments", "entity_id=light.kitchen_lamp"},
416419
"is not an entity id": {"service", "call", "light.turn_on", "--data", `{"entity_id":"Kitchen light"}`},
420+
"light.turn_on needs a target: add --entity": {"service", "call", "light.turn_on"},
421+
"service call takes the target and data as": {"service", "call", "light.turn_on", "--brightness", "40"},
422+
"hass-cli service call light.turn_off": {"light.turn_off", "light.kitchen"},
423+
"did you mean state": {"stat", "list"},
417424
}
418425
for want, args := range cases {
419426
t.Run(want, func(t *testing.T) {

0 commit comments

Comments
 (0)