+1
-1
@@ -52,7 +52,7 @@ func Bridges(r gin.IRoutes) {
|
||||
return []models.Bridge{bridge}, nil
|
||||
}
|
||||
|
||||
bridges, err := driver.SearchBridge(ctxOf(c), body.Address, body.DryRun)
|
||||
bridges, err := driver.SearchBridge(ctxOf(c), body.Address, body.Token, body.DryRun)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"git.aiterp.net/lucifer/new-server/internal/drivers"
|
||||
"git.aiterp.net/lucifer/new-server/internal/drivers/hue"
|
||||
"git.aiterp.net/lucifer/new-server/internal/drivers/lifx"
|
||||
"git.aiterp.net/lucifer/new-server/internal/drivers/mill"
|
||||
"git.aiterp.net/lucifer/new-server/internal/drivers/nanoleaf"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"sync"
|
||||
@@ -18,6 +19,7 @@ func DriverProvider() models.DriverProvider {
|
||||
models.DTNanoLeaf: &nanoleaf.Driver{},
|
||||
models.DTHue: &hue.Driver{},
|
||||
models.DTLIFX: &lifx.Driver{},
|
||||
models.DTMill: &mill.Driver{},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Find bridge
|
||||
bridges, err := driver.SearchBridge(context.Background(), *flagAddress, !*flagPair)
|
||||
bridges, err := driver.SearchBridge(context.Background(), *flagAddress, *flagToken, !*flagPair)
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to search bridge:", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
func main() {
|
||||
buf := make([]byte, 8)
|
||||
_, _ = io.ReadFull(rand.Reader, buf)
|
||||
|
||||
fmt.Printf("%x", buf)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ type Driver struct {
|
||||
bridges []*Bridge
|
||||
}
|
||||
|
||||
func (d *Driver) SearchBridge(ctx context.Context, address string, dryRun bool) ([]models.Bridge, error) {
|
||||
func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun bool) ([]models.Bridge, error) {
|
||||
if address == "" {
|
||||
if !dryRun {
|
||||
return nil, models.ErrAddressOnlyDryRunnable
|
||||
|
||||
@@ -14,7 +14,7 @@ type Driver struct {
|
||||
bridges []*Bridge
|
||||
}
|
||||
|
||||
func (d *Driver) SearchBridge(ctx context.Context, address string, _ bool) ([]models.Bridge, error) {
|
||||
func (d *Driver) SearchBridge(ctx context.Context, address, _ string, _ bool) ([]models.Bridge, error) {
|
||||
if address == "" {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package mill
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type bridge struct {
|
||||
mu sync.Mutex
|
||||
|
||||
luciferID int
|
||||
username string
|
||||
password string
|
||||
|
||||
token string
|
||||
userId int
|
||||
mustRefreshBy time.Time
|
||||
|
||||
luciferMillIDMap map[int]int
|
||||
millLuciferIDMap map[int]int
|
||||
}
|
||||
|
||||
func (b *bridge) listDevices(ctx context.Context) ([]models.Device, error) {
|
||||
err := b.authenticate(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var shlRes listHomeResBody
|
||||
err = b.command(ctx, "selectHomeList", listHomeReqBody{}, &shlRes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
devices := make([]millDevice, 0, 16)
|
||||
for _, home := range shlRes.HomeList {
|
||||
var gidRes listDeviceResBody
|
||||
err = b.command(ctx, "getIndependentDevices", listDeviceReqBody{HomeID: home.HomeID}, &gidRes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
devices = append(devices, gidRes.DeviceInfo...)
|
||||
}
|
||||
|
||||
luciferDevices := make([]models.Device, len(devices), len(devices))
|
||||
for i, device := range devices {
|
||||
luciferDevices[i] = models.Device{
|
||||
ID: b.millLuciferIDMap[device.DeviceID],
|
||||
BridgeID: b.luciferID,
|
||||
InternalID: fmt.Sprintf("%d", device.DeviceID),
|
||||
Icon: "heater",
|
||||
Name: device.DeviceName,
|
||||
Capabilities: []models.DeviceCapability{models.DCTemperatureControl, models.DCPower},
|
||||
ButtonNames: nil,
|
||||
DriverProperties: map[string]interface{}{
|
||||
"subDomain": fmt.Sprintf("%d", device.SubDomainID),
|
||||
},
|
||||
UserProperties: nil,
|
||||
SceneAssignments: nil,
|
||||
SceneState: nil,
|
||||
State: models.DeviceState{
|
||||
Power: device.PowerStatus > 0,
|
||||
Temperature: device.HolidayTemp,
|
||||
},
|
||||
Tags: nil,
|
||||
}
|
||||
}
|
||||
|
||||
return luciferDevices, nil
|
||||
}
|
||||
|
||||
func (b *bridge) pushStateChange(ctx context.Context, deviceModel models.Device) error {
|
||||
b.mu.Lock()
|
||||
if b.luciferMillIDMap == nil {
|
||||
b.luciferMillIDMap = make(map[int]int, 4)
|
||||
b.millLuciferIDMap = make(map[int]int, 4)
|
||||
}
|
||||
|
||||
if b.luciferMillIDMap[deviceModel.ID] == 0 {
|
||||
millID, _ := strconv.Atoi(deviceModel.InternalID)
|
||||
b.luciferMillIDMap[deviceModel.ID] = millID
|
||||
b.millLuciferIDMap[millID] = deviceModel.ID
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
status := 0
|
||||
if deviceModel.State.Power {
|
||||
status = 1
|
||||
}
|
||||
powerReq := deviceControlReqBody{
|
||||
SubDomain: deviceModel.DriverProperties["subDomain"].(string),
|
||||
DeviceID: b.luciferMillIDMap[deviceModel.ID],
|
||||
TestStatus: 1,
|
||||
Status: status,
|
||||
}
|
||||
err := b.command(ctx, "deviceControl", powerReq, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tempReq := changeInfoReqBody{
|
||||
DeviceID: b.luciferMillIDMap[deviceModel.ID],
|
||||
Value: deviceModel.State.Temperature,
|
||||
TimeZoneNum: "+02:00",
|
||||
Key: "holidayTemp",
|
||||
}
|
||||
err = b.command(ctx, "changeDeviceInfo", tempReq, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bridge) command(ctx context.Context, command string, payload interface{}, target interface{}) error {
|
||||
err := b.authenticate(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
url := serviceEndpoint + command
|
||||
method := "POST"
|
||||
nonce := makeNonce()
|
||||
timestamp := fmt.Sprintf("%d", time.Now().Unix())
|
||||
timeout := "300"
|
||||
|
||||
h := sha1.New()
|
||||
h.Write([]byte(timeout))
|
||||
h.Write([]byte(timestamp))
|
||||
h.Write([]byte(nonce))
|
||||
h.Write([]byte(b.token))
|
||||
signature := fmt.Sprintf("%x", h.Sum(nil))
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
addDefaultHeaders(req)
|
||||
req.Header.Add("X-Zc-Timestamp", timestamp)
|
||||
req.Header.Add("X-Zc-Timeout", timeout)
|
||||
req.Header.Add("X-Zc-Nonce", nonce)
|
||||
req.Header.Add("X-Zc-User-Id", fmt.Sprintf("%d", b.userId))
|
||||
req.Header.Add("X-Zc-User-Signature", signature)
|
||||
req.Header.Add("X-Zc-Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return models.ErrCannotForwardRequest
|
||||
} else if res.StatusCode != 200 {
|
||||
return models.ErrIncorrectToken
|
||||
}
|
||||
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = json.NewDecoder(res.Body).Decode(&target)
|
||||
if err != nil {
|
||||
return models.ErrUnexpectedResponse
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bridge) authenticate(ctx context.Context) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.mustRefreshBy.Before(time.Now().Add(-1 * time.Minute)) {
|
||||
body, err := json.Marshal(authReqBody{
|
||||
Account: b.username,
|
||||
Password: b.password,
|
||||
})
|
||||
if err != nil {
|
||||
return models.ErrMissingToken
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", accountEndpoint + "login", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return models.ErrMissingToken
|
||||
}
|
||||
|
||||
addDefaultHeaders(req)
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return models.ErrCannotForwardRequest
|
||||
} else if res.StatusCode != 200 {
|
||||
return models.ErrIncorrectToken
|
||||
}
|
||||
|
||||
var resBody authResBody
|
||||
err = json.NewDecoder(res.Body).Decode(&resBody)
|
||||
if err != nil {
|
||||
return models.ErrBridgeSearchFailed
|
||||
}
|
||||
|
||||
log.Printf("Mill: Authenticated as %s", resBody.NickName)
|
||||
b.userId = resBody.UserID
|
||||
b.token = resBody.Token
|
||||
b.mustRefreshBy, err = time.ParseInLocation("2006-01-02 15:04:05", resBody.TokenExpire, location)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeNonce() string {
|
||||
buf := make([]byte, 8)
|
||||
_, _ = io.ReadFull(rand.Reader, buf)
|
||||
|
||||
return fmt.Sprintf("%x", buf)
|
||||
}
|
||||
|
||||
func addDefaultHeaders(req *http.Request) {
|
||||
req.Header.Add("Content-Type", "application/x-zc-object")
|
||||
req.Header.Add("Connection", "Keep-Alive")
|
||||
req.Header.Add("X-Zc-Major-Domain", "seanywell")
|
||||
req.Header.Add("X-Zc-Msg-Name", "millService")
|
||||
req.Header.Add("X-Zc-Sub-Domain", "milltype")
|
||||
req.Header.Add("X-Zc-Seq-Id", "1")
|
||||
req.Header.Add("X-Zc-Version", "1")
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package mill
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Driver struct {
|
||||
mu sync.Mutex
|
||||
bridges map[int]*bridge
|
||||
}
|
||||
|
||||
func (d *Driver) SearchBridge(ctx context.Context, address, token string, _ bool) ([]models.Bridge, error) {
|
||||
bridgeData := models.Bridge{
|
||||
Name: fmt.Sprintf("Mill account (%s)", address),
|
||||
Driver: models.DTMill,
|
||||
Address: address,
|
||||
Token: token,
|
||||
}
|
||||
|
||||
b, err := d.ensureBridge(bridgeData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = b.authenticate(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []models.Bridge{bridgeData}, nil
|
||||
}
|
||||
|
||||
func (d *Driver) SearchDevices(context.Context, models.Bridge, time.Duration) ([]models.Device, error) {
|
||||
// You would have to configure devices with the Mill app, unfortunately.
|
||||
return []models.Device{}, nil
|
||||
}
|
||||
|
||||
func (d *Driver) ListDevices(ctx context.Context, bridge models.Bridge) ([]models.Device, error) {
|
||||
b, err := d.ensureBridge(bridge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.listDevices(ctx)
|
||||
}
|
||||
|
||||
func (d *Driver) Publish(ctx context.Context, bridge models.Bridge, devices []models.Device) error {
|
||||
b, err := d.ensureBridge(bridge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, device := range devices {
|
||||
err = b.pushStateChange(ctx, device)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Driver) Run(ctx context.Context, _ models.Bridge, _ chan<- models.Event) error {
|
||||
// TODO: Maybe do something with the thermostat on the device
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Driver) ensureBridge(model models.Bridge) (*bridge, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.bridges == nil {
|
||||
d.bridges = make(map[int]*bridge, 4)
|
||||
}
|
||||
|
||||
if d.bridges[model.ID] == nil {
|
||||
newBridge := &bridge{
|
||||
luciferID: model.ID,
|
||||
username: model.Address,
|
||||
password: model.Token,
|
||||
}
|
||||
|
||||
if model.ID <= 0 {
|
||||
return newBridge, nil
|
||||
}
|
||||
|
||||
d.bridges[model.ID] = newBridge
|
||||
}
|
||||
|
||||
return d.bridges[model.ID], nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package mill
|
||||
|
||||
import "time"
|
||||
|
||||
const accountEndpoint = "https://eurouter.ablecloud.cn:9005/zc-account/v1/"
|
||||
|
||||
const serviceEndpoint = "https://eurouter.ablecloud.cn:9005/millService/v1/"
|
||||
|
||||
type millHome struct {
|
||||
HomeID int64 `json:"homeId"`
|
||||
HomeName string `json:"homeName"`
|
||||
}
|
||||
|
||||
type millDevice struct {
|
||||
DeviceID int `json:"deviceId"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
PowerStatus int `json:"powerStatus"`
|
||||
HolidayTemp int `json:"holidayTemp"`
|
||||
CurrentTemp float64 `json:"currentTemp"`
|
||||
SubDomainID int `json:"subDomainId"`
|
||||
}
|
||||
|
||||
type authReqBody struct {
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type authResBody struct {
|
||||
Token string `json:"token"`
|
||||
UserID int `json:"userId"`
|
||||
NickName string `json:"nickName"`
|
||||
TokenExpire string `json:"tokenExpire"`
|
||||
}
|
||||
|
||||
type listHomeReqBody struct{}
|
||||
|
||||
type listHomeResBody struct {
|
||||
HomeList []millHome `json:"homeList"`
|
||||
}
|
||||
|
||||
type listDeviceReqBody struct {
|
||||
HomeID int64 `json:"homeId"`
|
||||
}
|
||||
|
||||
type listDeviceResBody struct {
|
||||
DeviceInfo []millDevice `json:"deviceInfo"`
|
||||
}
|
||||
|
||||
type changeInfoReqBody struct {
|
||||
HomeType int `json:"homeType"`
|
||||
DeviceID int `json:"deviceId"`
|
||||
Value int `json:"value"`
|
||||
TimeZoneNum string `json:"timeZoneNum"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type deviceControlReqBody struct {
|
||||
SubDomain string `json:"subDomain"`
|
||||
DeviceID int `json:"deviceId"`
|
||||
TestStatus int `json:"testStatus"`
|
||||
Operation int `json:"operation"`
|
||||
Status int `json:"status"`
|
||||
WindStatus int `json:"windStatus"`
|
||||
TempType int `json:"tempType"`
|
||||
PowerLevel int `json:"powerLevel"`
|
||||
}
|
||||
|
||||
var location *time.Location
|
||||
|
||||
func init() {
|
||||
myLocation, err := time.LoadLocation("Europe/Oslo")
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
|
||||
location = myLocation
|
||||
}
|
||||
@@ -17,7 +17,7 @@ type Driver struct {
|
||||
|
||||
// SearchBridge checks the bridge at the address. If it's not a dry-run, you must hold down the power button
|
||||
// before calling this function and wait for the pattern.
|
||||
func (d *Driver) SearchBridge(ctx context.Context, address string, dryRun bool) ([]models.Bridge, error) {
|
||||
func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun bool) ([]models.Bridge, error) {
|
||||
res, err := http.Get(fmt.Sprintf("http://%s/device_info", address))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -29,6 +29,7 @@ type deviceStateRecord struct {
|
||||
Kelvin int `db:"kelvin"`
|
||||
Power bool `db:"power"`
|
||||
Intensity float64 `db:"intensity"`
|
||||
Temperature int `db:"temperature"`
|
||||
}
|
||||
|
||||
type devicePropertyRecord struct {
|
||||
@@ -227,8 +228,8 @@ func (r *DeviceRepo) SaveMany(ctx context.Context, mode models.SaveMode, devices
|
||||
|
||||
if mode == 0 || mode&models.SMState != 0 {
|
||||
_, err = tx.NamedExecContext(ctx, `
|
||||
REPLACE INTO device_state(device_id, hue, saturation, kelvin, power, intensity)
|
||||
VALUES (:device_id, :hue, :saturation, :kelvin, :power, :intensity)
|
||||
REPLACE INTO device_state(device_id, hue, saturation, kelvin, power, intensity, temperature)
|
||||
VALUES (:device_id, :hue, :saturation, :kelvin, :power, :intensity, :temperature)
|
||||
`, deviceStateRecord{
|
||||
DeviceID: record.ID,
|
||||
Hue: device.State.Color.Hue,
|
||||
@@ -236,6 +237,7 @@ func (r *DeviceRepo) SaveMany(ctx context.Context, mode models.SaveMode, devices
|
||||
Kelvin: device.State.Color.Kelvin,
|
||||
Power: device.State.Power,
|
||||
Intensity: device.State.Intensity,
|
||||
Temperature: device.State.Temperature,
|
||||
})
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
@@ -379,6 +381,7 @@ func (r *DeviceRepo) populate(ctx context.Context, records []deviceRecord) ([]mo
|
||||
Kelvin: state.Kelvin,
|
||||
},
|
||||
Intensity: state.Intensity,
|
||||
Temperature: state.Temperature,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ var (
|
||||
DTHue DriverKind = "Hue"
|
||||
DTNanoLeaf DriverKind = "Nanoleaf"
|
||||
DTLIFX DriverKind = "LIFX"
|
||||
DTMill DriverKind = "Mill"
|
||||
)
|
||||
|
||||
var ValidDriverKinds = []DriverKind{
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ type DeviceState struct {
|
||||
Power bool `json:"power"`
|
||||
Color ColorValue `json:"color,omitempty"`
|
||||
Intensity float64 `json:"intensity,omitempty"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
Temperature int `json:"temperature"`
|
||||
}
|
||||
|
||||
type DeviceScene struct {
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ type DriverProvider interface {
|
||||
}
|
||||
|
||||
type Driver interface {
|
||||
SearchBridge(ctx context.Context, address string, dryRun bool) ([]Bridge, error)
|
||||
SearchBridge(ctx context.Context, address, token string, dryRun bool) ([]Bridge, error)
|
||||
SearchDevices(ctx context.Context, bridge Bridge, timeout time.Duration) ([]Device, error)
|
||||
ListDevices(ctx context.Context, bridge Bridge) ([]Device, error)
|
||||
Publish(ctx context.Context, bridge Bridge, devices []Device) error
|
||||
|
||||
@@ -15,6 +15,7 @@ var ErrIncorrectToken = errors.New("driver is not accepting authentication infor
|
||||
var ErrUnexpectedResponse = errors.New("driver api returned unexpected response (wrong driver selected?)")
|
||||
var ErrBridgeSearchFailed = errors.New("bridge search failed")
|
||||
var ErrAddressOnlyDryRunnable = errors.New("this address may only be used for a dry run")
|
||||
var ErrCannotForwardRequest = errors.New("driver is not able to forward requests")
|
||||
|
||||
var ErrInvalidAddress = errors.New("invalid mac address")
|
||||
var ErrPayloadTooShort = errors.New("payload too short")
|
||||
|
||||
@@ -168,7 +168,7 @@ func (c *EventCondition) checkDevice(key string, device Device) (matches bool, s
|
||||
return false, true
|
||||
}
|
||||
|
||||
return c.matches(strconv.FormatFloat(device.State.Temperature, 'f', -1, 64)), false
|
||||
return c.matches(strconv.Itoa(device.State.Temperature)), false
|
||||
case "scene":
|
||||
sceneId := -1
|
||||
for _, assignment := range device.SceneAssignments {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE device_state
|
||||
ADD COLUMN temperature INT NOT NULL DEFAULT 0;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE device_state
|
||||
DROP COLUMN temperature;
|
||||
-- +goose StatementEnd
|
||||
Reference in New Issue
Block a user