diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b439317 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +ignore_me_* \ No newline at end of file diff --git a/README.md b/README.md index 63cc327..f664d83 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,17 @@ Documentation, examples and more at [GoDoc](https://godoc.org/github.com/tjhorne ## Features and TODO - [x] Connecting to printers (`Connect()`) +- [ ] Printer discovery via mDNS - [x] Authenticating with local printers via Thingiverse (`AuthenticateWithThingiverse()`) - [ ] Authenticating with local printers via local authentication (pushing the knob) -- [ ] Authenticating with remote printers via MakerBot Reflector +- [x] Authenticating with remote printers via MakerBot Reflector - [x] Printer state updates (`HandleStateUpdate()`) - [x] Load filament method (`LoadFilament()`) - [x] Unload filament method (`UnloadFilament()`) - [x] Cancel method (`Cancel()`) - [x] Change machine name (`ChangeMachineName()`) - [ ] Send print files -- [ ] Camera stream/snapshots +- [x] Camera stream/snapshots - [ ] Get machine config - [ ] Write tests (will need to make a mock MakerBot RPC server) - [ ] Write examples diff --git a/auth.go b/auth.go index 65eebd5..12e1e8c 100644 --- a/auth.go +++ b/auth.go @@ -18,7 +18,7 @@ func (c *Client) httpGet(endpoint string, qs map[string]string) (map[string]inte } req.URL.RawQuery = q.Encode() - r, err := c.http.Do(req) + r, err := http.DefaultClient.Do(req) if err != nil { return nil, err } diff --git a/client.go b/client.go index 98522e1..d31b097 100644 --- a/client.go +++ b/client.go @@ -3,8 +3,9 @@ package makerbot import ( "encoding/json" "errors" - "log" - "net/http" + "strings" + + "github.com/tjhorner/makerbot-rpc/reflector" "github.com/tjhorner/makerbot-rpc/jsonrpc" ) @@ -17,31 +18,90 @@ type rpcSystemNotification struct { // Client represents an RPC client that can connect to // MakerBot 3D printers via the network. +// +// Calls to the printer (e.g. LoadFilament, Cancel, etc.) +// will block, so you may want to take this into consideration. type Client struct { - IP string - Port string - Printer *Printer - stateCbs []func(old, new *PrinterMetadata) - http *http.Client - rpc *jsonrpc.Client + IP string + Port string + Printer *Printer + stateCbs []func(old, new *PrinterMetadata) + cameraCh *chan CameraFrame + cameraCbs []func(*CameraFrame) + rpc *jsonrpc.Client } -// Connect connects to the printer and performs the initial handshake. +// ConnectLocal connects to a local printer and performs the initial handshake. // If it is successful, the Printer field will be populated with information // about the machine this client is connected to. -func (c *Client) Connect() error { - if c.IP == "" || c.Port == "" { - return errors.New("IP and Port are required fields for Client") +// +// After using ConnectLocal, you must use one of the AuthenticateWith* methods +// to authenticate with the printer. +func (c *Client) ConnectLocal(ip, port string) error { + c.IP = ip + c.Port = port + + err := c.connectRPC() + if err != nil { + return err + } + + return c.handshake() +} + +// ConnectRemote uses MakerBot Reflector to remotely connect to a printer +// and performs the initial handshake. It will connect to printer with ID +// `id` and will authenticate using the Thingiverse token `accessToken`. +// +// Since authentication is already performed by Thingiverse, you do not need +// to perform any additional authentication after it is connected. +func (c *Client) ConnectRemote(id, accessToken string) error { + refl := reflector.NewClient(accessToken) + + call, err := refl.CallPrinter(id) + if err != nil { + return err + } + + split := strings.Split(call.Call.Relay, ":") + c.IP = split[0] + c.Port = split[1] + + err = c.connectRPC() + if err != nil { + return err + } + + ok, err := c.sendAuthPacket(id, call) + if err != nil { + return err + } + + if !*ok { + return errors.New("could not authenticate with printer via Reflector call") } - rpc := jsonrpc.NewClient(c.IP, c.Port) - err := rpc.Connect() + return c.handshake() +} + +func (c *Client) connectRPC() error { + c.rpc = jsonrpc.NewClient(c.IP, c.Port) + return c.rpc.Connect() +} + +func (c *Client) handshake() error { + printer, err := c.sendHandshake() if err != nil { - log.Fatalln(err) + return err } + c.Printer = printer + onStateChange := func(message json.RawMessage) { - oldState := c.Printer.Metadata + var oldState *PrinterMetadata + if c.Printer != nil { + oldState = c.Printer.Metadata + } var newState rpcSystemNotification json.Unmarshal(message, &newState) @@ -49,21 +109,36 @@ func (c *Client) Connect() error { c.Printer.Metadata = newState.Info for _, cb := range c.stateCbs { - cb(oldState, newState.Info) + go cb(oldState, newState.Info) // Async so we don't block other callbacks } } - rpc.Subscribe("system_notification", onStateChange) - rpc.Subscribe("state_notification", onStateChange) + c.rpc.Subscribe("system_notification", onStateChange) + c.rpc.Subscribe("state_notification", onStateChange) - c.rpc = rpc + c.rpc.Subscribe("camera_frame", func(m json.RawMessage) { + if len(c.cameraCbs) == 0 { + go c.endCameraStream() + } - printer, err := c.handshake() - if err != nil { - return err - } + metadata := parseCameraFrameMetadata(c.rpc.GetRawData(16)) - c.Printer = printer + data := c.rpc.GetRawData(int(metadata.FileSize)) + + frame := CameraFrame{ + Data: data, + Metadata: &metadata, + } + + if c.cameraCh != nil { + *c.cameraCh <- frame + c.cameraCh = nil + } + + for _, cb := range c.cameraCbs { + go cb(&frame) // Async so we don't block other callbacks + } + }) return nil } @@ -88,6 +163,12 @@ func (c *Client) HandleStateChange(cb func(old, new *PrinterMetadata)) { c.stateCbs = append(c.stateCbs, cb) } +// HandleCameraFrame calls `cb` when the printer sends a camera frame. +func (c *Client) HandleCameraFrame(cb func(frame *CameraFrame)) { + c.cameraCbs = append(c.cameraCbs, cb) + go c.requestCameraStream() +} + func (c *Client) call(method string, args, result interface{}) error { if c.rpc == nil { return errors.New("client is not connected to printer") @@ -96,7 +177,7 @@ func (c *Client) call(method string, args, result interface{}) error { return c.rpc.Call(method, args, &result) } -func (c *Client) handshake() (*Printer, error) { +func (c *Client) sendHandshake() (*Printer, error) { var reply Printer return &reply, c.call("handshake", rpcEmptyParams{}, &reply) } @@ -113,6 +194,23 @@ func (c *Client) authenticate(accessToken string) (*json.RawMessage, error) { return &reply, c.call("authenticate", rpcAuthenticateParams{accessToken}, &reply) } +type rpcAuthPacketParams struct { + CallID string `json:"call_id"` + ClientCode string `json:"client_code"` + PrinterID string `json:"printer_id"` +} + +func (c *Client) sendAuthPacket(id string, pc *reflector.CallPrinterResponse) (*bool, error) { + params := rpcAuthPacketParams{ + CallID: pc.Call.ID, + ClientCode: pc.Call.ClientCode, + PrinterID: id, + } + + var reply bool + return &reply, c.call("auth_packet", params, &reply) +} + // AuthenticateWithThingiverse performs authentication with the printers // by using a Thingiverse token:username pair. // @@ -166,3 +264,27 @@ func (c *Client) ChangeMachineName(name string) (*json.RawMessage, error) { var reply json.RawMessage return &reply, c.call("cancel", rpcChangeMachineNameParams{name}, &reply) } + +func (c *Client) requestCameraStream() error { + return c.call("request_camera_stream", rpcEmptyParams{}, nil) +} + +func (c *Client) endCameraStream() error { + return c.call("end_camera_stream", rpcEmptyParams{}, nil) +} + +// GetCameraFrame requests a single frame from the printer's camera +func (c *Client) GetCameraFrame() (*CameraFrame, error) { + ch := make(chan CameraFrame) + c.cameraCh = &ch + + err := c.requestCameraStream() + if err != nil { + return nil, err + } + + data := <-ch + close(ch) + + return &data, nil +} diff --git a/jsonrpc/client.go b/jsonrpc/client.go index b839c95..4b2b7af 100644 --- a/jsonrpc/client.go +++ b/jsonrpc/client.go @@ -51,6 +51,7 @@ type Client struct { Port string rsps map[string]chan rpcResponse subs map[string]func(json.RawMessage) + jr JSONReader conn *net.TCPConn } @@ -66,47 +67,42 @@ func (c *Client) Connect() error { return err } - jd := make(chan []byte) - jr := NewJSONReader(jd) + done := func(j []byte) error { + // need to determine if this is a request or a response + var resp rpcResponse + err := json.Unmarshal(j, &resp) + if err != nil { + return err + } - go func() { - for { - b := make([]byte, 1) - conn.Read(b) + if resp.Result == nil && resp.Error == nil && resp.ID == nil { + // Request + var req rpcServerRequest + json.Unmarshal(j, &req) + + if sub, ok := c.subs[req.Method]; ok { - jr.FeedByte(b[0]) + go sub(req.Params) + } + } else if resp.ID != nil { + // Response + if rsp, ok := c.rsps[*resp.ID]; ok { + go func() { rsp <- resp }() + delete(c.rsps, *resp.ID) + } } - }() + + return nil + } + + c.jr = NewJSONReader(done) go func() { for { - j := <-jd - // log.Println(string(j)) - - // need to determine if this is a request or a response - var resp rpcResponse - json.Unmarshal(j, &resp) - - // log.Printf("recv'd json: %+v\n", resp) - - if resp.Result == nil && resp.Error == nil && resp.ID == nil { - // Request - var req rpcServerRequest - json.Unmarshal(j, &req) - - if sub, ok := c.subs[req.Method]; ok { - // log.Printf("request: %+v\n", req) - sub(req.Params) - } - } else { - // Response - if rsp, ok := c.rsps[*resp.ID]; ok { - // log.Printf("response: %+v\n", resp) - rsp <- resp - delete(c.rsps, *resp.ID) - // log.Printf("%+v\n", c.rsps) - } - } + b := make([]byte, 1) + conn.Read(b) + + c.jr.FeedByte(b[0]) } }() @@ -137,7 +133,7 @@ func (c *Client) Call(serviceMethod string, args, reply interface{}) error { } id := uuid.New().String() - // log.Printf("ID: %+v\n", id) + req := rpcClientRequest{ Params: args, } @@ -161,7 +157,7 @@ func (c *Client) Call(serviceMethod string, args, reply interface{}) error { if reply != nil { resp := <-msg - // log.Println("this is good " + serviceMethod) + if resp.Error != nil { return resp.Error } @@ -171,6 +167,7 @@ func (c *Client) Call(serviceMethod string, args, reply interface{}) error { } json.Unmarshal(*resp.Result, &reply) + close(msg) } return nil @@ -199,3 +196,10 @@ func (c *Client) Subscribe(namespace string, cb func(message json.RawMessage)) e func (c *Client) Unsubscribe(namespace string) { delete(c.subs, namespace) } + +// GetRawData grabs raw data from the TCP connection until +// `length` is reached. The captured data is returned as an +// array of bytes. +func (c *Client) GetRawData(length int) []byte { + return c.jr.GetRawData(length) +} diff --git a/jsonrpc/jsonreader.go b/jsonrpc/jsonreader.go index 6e9f4a0..7735c0e 100644 --- a/jsonrpc/jsonreader.go +++ b/jsonrpc/jsonreader.go @@ -17,11 +17,14 @@ type JSONReader struct { state jsonReaderState stack []byte buffer []byte - done chan []byte + rawBuf []byte + done func([]byte) error + rawCh *chan []byte + rawExp int } // NewJSONReader creates a new JSONReader instance -func NewJSONReader(done chan []byte) JSONReader { +func NewJSONReader(done func([]byte) error) JSONReader { return JSONReader{done: done} } @@ -30,15 +33,20 @@ func (r *JSONReader) reset() { r.stack = nil r.buffer = nil - // log.Println("jsonreader: reset") + r.rawBuf = nil + r.rawCh = nil + r.rawExp = 0 } func (r *JSONReader) send() { - if r.done != nil { - r.done <- r.buffer + if r.rawCh != nil || r.state == state4 { + return } - r.reset() + err := r.done(r.buffer) + if err == nil { + r.reset() + } } func (r *JSONReader) transition(b byte) { @@ -90,12 +98,21 @@ func (r *JSONReader) transition(b byte) { case state3: r.state = state2 break + + case state4: + r.rawBuf = append(r.rawBuf, b) + if r.rawCh != nil && len(r.rawBuf) == r.rawExp { + *r.rawCh <- r.rawBuf + r.reset() + } + break } } // FeedByte feeds the JSONReader a single byte func (r *JSONReader) FeedByte(b byte) { r.buffer = append(r.buffer, b) + r.transition(b) } @@ -105,3 +122,21 @@ func (r *JSONReader) FeedBytes(bs []byte) { r.FeedByte(b) } } + +// GetRawData grabs raw data from the TCP connection until +// `length` is reached. The captured data is returned as an +// array of bytes. +func (r *JSONReader) GetRawData(length int) []byte { + ch := make(chan []byte) + r.rawCh = &ch + r.state = state4 + + r.rawBuf = r.buffer + r.buffer = nil + r.rawExp = length + + data := <-ch + close(ch) + + return data +} diff --git a/makerbot.go b/makerbot.go index 56fc8f6..f80bb72 100644 --- a/makerbot.go +++ b/makerbot.go @@ -3,8 +3,6 @@ Package makerbot is a Go client library for MakerBot printers. */ package makerbot -import "net/http" - // These constants are used to communicate with the printer // and are apparently hard-coded @@ -20,7 +18,6 @@ func NewClient(ip string) Client { return Client{ IP: ip, Port: "9999", - http: &http.Client{}, } } @@ -31,6 +28,5 @@ func NewClientWithPort(ip, port string) Client { return Client{ IP: ip, Port: port, - http: &http.Client{}, } } diff --git a/reflector/client.go b/reflector/client.go new file mode 100644 index 0000000..e628371 --- /dev/null +++ b/reflector/client.go @@ -0,0 +1,97 @@ +package reflector + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strconv" + "strings" +) + +// Client is an HTTP client that talks to MakerBot Reflector. +type Client struct { + BaseURL string + accessToken string + http *http.Client +} + +func (c *Client) url(endpoint string) string { + return fmt.Sprintf("%s%s", c.BaseURL, endpoint) +} + +func (c *Client) httpGet(endpoint string) (*json.RawMessage, error) { + req, err := http.NewRequest("GET", c.url(endpoint), nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.accessToken)) + + r, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer r.Body.Close() + + resp, err := ioutil.ReadAll(r.Body) + if err != nil { + return nil, err + } + + jr := json.RawMessage(resp) + + return &jr, nil +} + +func (c *Client) httpPost(endpoint string, params map[string]string) (*json.RawMessage, error) { + data := url.Values{} + for k, v := range params { + data.Set(k, v) + } + + req, err := http.NewRequest("POST", c.url(endpoint), strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.accessToken)) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Content-Length", strconv.Itoa(len(data.Encode()))) + + r, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer r.Body.Close() + + resp, err := ioutil.ReadAll(r.Body) + if err != nil { + return nil, err + } + + jr := json.RawMessage(resp) + + return &jr, nil +} + +func (c *Client) GetPrinters() (*json.RawMessage, error) { + return c.httpGet("/printers") +} + +func (c *Client) GetPrinter(id string) (*json.RawMessage, error) { + return c.httpGet(fmt.Sprintf("/printers/%s", id)) +} + +func (c *Client) CallPrinter(id string) (*CallPrinterResponse, error) { + resp, err := c.httpPost("/call", map[string]string{"printer_id": id}) + if err != nil { + return nil, err + } + + var res CallPrinterResponse + json.Unmarshal(*resp, &res) + + return &res, nil +} diff --git a/reflector/reflector.go b/reflector/reflector.go new file mode 100644 index 0000000..9870cf5 --- /dev/null +++ b/reflector/reflector.go @@ -0,0 +1,14 @@ +/* +Package reflector is a Go client library for the MakerBot Reflector API. +*/ +package reflector + +import "net/http" + +func NewClient(accessToken string) Client { + return Client{ + BaseURL: "https://reflector.makerbot.com", + accessToken: accessToken, + http: &http.Client{}, + } +} diff --git a/reflector/types.go b/reflector/types.go new file mode 100644 index 0000000..8ee5ba9 --- /dev/null +++ b/reflector/types.go @@ -0,0 +1,15 @@ +package reflector + +import "net" + +type CallPrinterResponse struct { + Call struct { + ID string `json:"id"` + Relay string `json:"relay"` + ClientCode string `json:"client_code"` + } `json:"call"` +} + +func (r *CallPrinterResponse) RelayAddr() (*net.TCPAddr, error) { + return net.ResolveTCPAddr("tcp", r.Call.Relay) +} diff --git a/utils.go b/utils.go index 9e306a1..3dfdc44 100644 --- a/utils.go +++ b/utils.go @@ -1,6 +1,7 @@ package makerbot import ( + "encoding/binary" "strconv" "strings" "time" @@ -25,3 +26,39 @@ func (t *epochTime) UnmarshalJSON(s []byte) (err error) { } func (t epochTime) String() string { return time.Time(t).String() } + +// CameraFrameFormat specifies which format a camera frame is in +type CameraFrameFormat uint32 + +const ( + // CameraFrameFormatInvalid means that the camera frame is invalid(?) + CameraFrameFormatInvalid CameraFrameFormat = iota + // CameraFrameFormatYUYV means that the camera frame is in YUYV format + CameraFrameFormatYUYV + // CameraFrameFormatJPEG means that the camera frame is in JPEG format + CameraFrameFormatJPEG +) + +// CameraFrame is a single camera snapshot returned by the printer +type CameraFrame struct { + Data []byte + Metadata *CameraFrameMetadata +} + +// CameraFrameMetadata holds information about a camera frame returned +// by the printer +type CameraFrameMetadata struct { + FileSize uint32 // Frame's file size in bytes + Width uint32 // Frame's width in pixels + Height uint32 // Frame's height in pixels + Format CameraFrameFormat // Format that the frame is in (invalid, YUYV, JPEG) +} + +func parseCameraFrameMetadata(packed []byte) CameraFrameMetadata { + return CameraFrameMetadata{ + FileSize: binary.BigEndian.Uint32(packed[0:4]) - 16, + Width: binary.BigEndian.Uint32(packed[4:8]), + Height: binary.BigEndian.Uint32(packed[8:12]), + Format: CameraFrameFormat(binary.BigEndian.Uint32(packed[12:16])), + } +}