Added Radio.ID and refactored the Stats interface
This commit is contained in:
335
protocol/aprs/_attic/data_mice.go
Normal file
335
protocol/aprs/_attic/data_mice.go
Normal file
@@ -0,0 +1,335 @@
|
||||
package aprs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type MicE struct {
|
||||
HasMessaging bool `json:"has_messaging"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Altitude float64 `json:"altitude,omitempty"` // Altitude (in meters)
|
||||
Comment string `json:"comment"`
|
||||
Symbol string `json:"symbol"`
|
||||
Velocity *Velocity `json:"velocity,omitempty"` // Velocity encoded in the payload.
|
||||
Telemetry *Telemetry `json:"telemetry,omitempty"` // Telemetry data
|
||||
}
|
||||
|
||||
func (m MicE) String() string {
|
||||
return m.Comment
|
||||
}
|
||||
|
||||
type micEDecoder struct{}
|
||||
|
||||
func (d micEDecoder) CanDecode(frame Frame) bool {
|
||||
switch frame.Raw.Type() {
|
||||
case '`', '\'':
|
||||
return len(frame.Raw) >= 9 && len(frame.Destination.Call) >= 6
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (d micEDecoder) Decode(frame Frame) (data Data, err error) {
|
||||
lat, _, longOffset, longDir, err := decodeMicECallsign([]byte(frame.Destination.Call))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info := []byte(frame.Raw[1:9])
|
||||
long := decodeMicELongitude(info[:3], longOffset, longDir)
|
||||
|
||||
pos := &MicE{
|
||||
Latitude: float64(lat),
|
||||
Longitude: float64(long),
|
||||
}
|
||||
pos.Symbol = string([]byte{info[7], info[6]})
|
||||
pos.Velocity = parseMicECourseAndSpeed(info[3:6])
|
||||
|
||||
var comment string
|
||||
if comment, pos.HasMessaging = parseMicERadioModel(string(frame.Raw[9:])); comment != "" {
|
||||
original := comment
|
||||
if pos.Altitude, comment, err = parseMicEAltitude(comment); err != nil {
|
||||
comment = original
|
||||
}
|
||||
if pos.Telemetry, comment, err = parseBase91Telemetry(comment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pos.Comment = comment
|
||||
}
|
||||
|
||||
return pos, nil
|
||||
}
|
||||
|
||||
type micEMessageBit uint8
|
||||
|
||||
const (
|
||||
micEMessageBitZero micEMessageBit = iota
|
||||
micEMessageBitCustom
|
||||
micEMessageBitStandard
|
||||
micEMessageBitInvalid
|
||||
)
|
||||
|
||||
func decodeMicEMessageBit(v uint8) micEMessageBit {
|
||||
switch {
|
||||
case v >= '0' && v <= '9' || v == 'L':
|
||||
return micEMessageBitZero
|
||||
case v >= 'A' && v <= 'K':
|
||||
return micEMessageBitCustom
|
||||
case v >= 'P' && v <= 'Z':
|
||||
return micEMessageBitStandard
|
||||
default:
|
||||
return micEMessageBitInvalid
|
||||
}
|
||||
}
|
||||
|
||||
type micEPositionCommentType uint8
|
||||
|
||||
const (
|
||||
M0 micEPositionCommentType = iota
|
||||
M1
|
||||
M2
|
||||
M3
|
||||
M4
|
||||
M5
|
||||
M6
|
||||
C0
|
||||
C1
|
||||
C2
|
||||
C3
|
||||
C4
|
||||
C5
|
||||
C6
|
||||
Emergency
|
||||
Invalid
|
||||
)
|
||||
|
||||
var micEPositionCommentTypeMap = map[micEMessageBit]micEPositionCommentType{
|
||||
(0 << 4) | (0 << 2) | 0: Emergency,
|
||||
(1 << 4) | (1 << 2) | 1: C0,
|
||||
(1 << 4) | (1 << 2) | 0: C1,
|
||||
(1 << 4) | (0 << 2) | 1: C2,
|
||||
(1 << 4) | (0 << 2) | 0: C3,
|
||||
(0 << 4) | (1 << 2) | 1: C4,
|
||||
(0 << 4) | (1 << 2) | 0: C5,
|
||||
(0 << 4) | (0 << 2) | 1: C6,
|
||||
(2 << 4) | (2 << 2) | 2: M0,
|
||||
(2 << 4) | (2 << 2) | 0: M1,
|
||||
(2 << 4) | (0 << 2) | 2: M2,
|
||||
(2 << 4) | (0 << 2) | 0: M3,
|
||||
(0 << 4) | (2 << 2) | 2: M4,
|
||||
(0 << 4) | (2 << 2) | 0: M5,
|
||||
(0 << 4) | (0 << 2) | 2: M6,
|
||||
}
|
||||
|
||||
func decodeMicEPositionCommentType(bits []micEMessageBit) micEPositionCommentType {
|
||||
if v, ok := micEPositionCommentTypeMap[(bits[0]&0x3)<<4|(bits[1]&0x03)<<2|(bits[2]&0x03)]; ok {
|
||||
return v
|
||||
}
|
||||
return Invalid
|
||||
}
|
||||
|
||||
func decodeMicECallsign(call []byte) (lat Latitude, kind micEPositionCommentType, longOffset int, longDir int, err error) {
|
||||
if len(call) != 6 {
|
||||
err = io.ErrUnexpectedEOF
|
||||
return
|
||||
}
|
||||
|
||||
var latDir byte = 'X'
|
||||
if (call[3] >= '0' && call[3] <= '9') || call[3] == 'L' {
|
||||
latDir = 'S'
|
||||
} else if call[3] >= 'P' && call[3] <= 'Z' {
|
||||
latDir = 'N'
|
||||
}
|
||||
|
||||
latBytes := []byte{
|
||||
decodeLatitudeDigit(call[0]),
|
||||
decodeLatitudeDigit(call[1]),
|
||||
decodeLatitudeDigit(call[2]),
|
||||
decodeLatitudeDigit(call[3]),
|
||||
'.',
|
||||
decodeLatitudeDigit(call[4]),
|
||||
decodeLatitudeDigit(call[5]),
|
||||
latDir,
|
||||
}
|
||||
if err = lat.ParseUncompressed(latBytes); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
kind = decodeMicEPositionCommentType([]micEMessageBit{
|
||||
decodeMicEMessageBit(call[0]),
|
||||
decodeMicEMessageBit(call[1]),
|
||||
decodeMicEMessageBit(call[2]),
|
||||
})
|
||||
|
||||
switch {
|
||||
case (call[4] >= '0' && call[4] <= '9') || call[4] == 'L':
|
||||
longOffset = 0
|
||||
case call[4] >= 'P' && call[4] <= 'Z':
|
||||
longOffset = 100
|
||||
}
|
||||
|
||||
switch {
|
||||
case (call[5] >= '0' && call[5] <= '9') || call[5] == 'L':
|
||||
longDir = -1
|
||||
case call[5] >= 'P' && call[5] <= 'Z':
|
||||
longDir = +1
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func decodeLatitudeDigit(c uint8) uint8 {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return c
|
||||
case c >= 'A' && c <= 'J':
|
||||
return c - 17
|
||||
case c == 'K' || c == 'L' || c == 'Z':
|
||||
return ' '
|
||||
case c >= 'P' && c <= 'Y':
|
||||
return c - 32
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func decodeMicELongitude(b []byte, offset, dir int) Longitude {
|
||||
if len(b) != 3 {
|
||||
return 0
|
||||
}
|
||||
|
||||
d := int(b[0]) - 28 + offset
|
||||
if d >= 180 && d <= 189 {
|
||||
d -= 80
|
||||
} else if d >= 190 && d <= 199 {
|
||||
d -= 190
|
||||
}
|
||||
|
||||
m := int(b[1] - 28)
|
||||
if m >= 60 {
|
||||
m -= 60
|
||||
}
|
||||
|
||||
h := int(b[2] - 28)
|
||||
|
||||
return LongitudeFromDMH(d, m, h, dir < 0)
|
||||
}
|
||||
|
||||
func parseMicECourseAndSpeed(data []byte) (out *Velocity) {
|
||||
var (
|
||||
sp = data[0] - 28
|
||||
dc = data[1] - 28
|
||||
se = data[2] - 28
|
||||
speedKnots = float64(sp)*10 + math.Floor(float64(dc)/10)
|
||||
courseDeg = ((int(dc) % 10) * 100) + int(se)
|
||||
)
|
||||
if speedKnots >= 800 {
|
||||
speedKnots -= 800
|
||||
}
|
||||
if courseDeg >= 400 {
|
||||
courseDeg -= 400
|
||||
}
|
||||
return &Velocity{
|
||||
Course: float64(courseDeg),
|
||||
Speed: knotsToMetersPerSecond(speedKnots),
|
||||
}
|
||||
}
|
||||
|
||||
func parseMicEAltitude(data string) (altitude float64, comment string, err error) {
|
||||
if len(data) < 4 || data[3] != '}' {
|
||||
return 0, data, nil
|
||||
}
|
||||
|
||||
var value int
|
||||
if value, err = base91Decode(data[:3]); err != nil {
|
||||
return 0, "", fmt.Errorf("aprs: invalid altitude %q: %v", data, err)
|
||||
}
|
||||
|
||||
altitude = feetToMeters(float64(value - 10000))
|
||||
comment = data[4:]
|
||||
return
|
||||
}
|
||||
|
||||
func parseMicERadioModel(data string) (stripped string, hasMessaging bool) {
|
||||
if len(data) == 0 {
|
||||
return data, false
|
||||
}
|
||||
|
||||
switch data[0] {
|
||||
case '>', ']':
|
||||
stripped = strings.TrimRight(data[1:], "=") // Kenwood TH-D72 / Kenwood TM-D710
|
||||
stripped = strings.TrimRight(data[1:], "^") // Kenwood TH-D74
|
||||
stripped = strings.TrimRight(data[1:], "&") // Kenwood TH-D75
|
||||
case '`', '\'':
|
||||
hasMessaging = data[0] == '`'
|
||||
stripped = strings.TrimSuffix(data[1:], "_(") // Yaesu FT2D
|
||||
stripped = strings.TrimSuffix(data[1:], "_0") // Yaesu FT3D
|
||||
stripped = strings.TrimSuffix(data[1:], "_3") // Yaesu FT5D
|
||||
stripped = strings.TrimSuffix(data[1:], "|3") // Byonics TinyTrack 3
|
||||
stripped = strings.TrimSuffix(data[1:], "|4") // Byonics TinyTrack 4
|
||||
default:
|
||||
stripped = data
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func parseMicEGridSquare(data string) (latitude, longitude, altitude float64, comment string, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
var miceCodes = map[byte]map[int]string{
|
||||
'0': {0: "0", 1: "0", 2: "S", 3: "0", 4: "E"},
|
||||
'1': {0: "1", 1: "0", 2: "S", 3: "0", 4: "E"},
|
||||
'2': {0: "2", 1: "0", 2: "S", 3: "0", 4: "E"},
|
||||
'3': {0: "3", 1: "0", 2: "S", 3: "0", 4: "E"},
|
||||
'4': {0: "4", 1: "0", 2: "S", 3: "0", 4: "E"},
|
||||
'5': {0: "5", 1: "0", 2: "S", 3: "0", 4: "E"},
|
||||
'6': {0: "6", 1: "0", 2: "S", 3: "0", 4: "E"},
|
||||
'7': {0: "7", 1: "0", 2: "S", 3: "0", 4: "E"},
|
||||
'8': {0: "8", 1: "0", 2: "S", 3: "0", 4: "E"},
|
||||
'9': {0: "9", 1: "0", 2: "S", 3: "0", 4: "E"},
|
||||
'A': {0: "0", 1: "1 (Custom)"},
|
||||
'B': {0: "1", 1: "1 (Custom)"},
|
||||
'C': {0: "2", 1: "1 (Custom)"},
|
||||
'D': {0: "3", 1: "1 (Custom)"},
|
||||
'E': {0: "4", 1: "1 (Custom)"},
|
||||
'F': {0: "5", 1: "1 (Custom)"},
|
||||
'G': {0: "6", 1: "1 (Custom)"},
|
||||
'H': {0: "7", 1: "1 (Custom)"},
|
||||
'I': {0: "8", 1: "1 (Custom)"},
|
||||
'J': {0: "9", 1: "1 (Custom)"},
|
||||
'K': {0: " ", 1: "1 (Custom)"},
|
||||
'L': {0: " ", 1: "0", 2: "S", 3: "0", 4: "E"},
|
||||
'P': {0: "0", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
||||
'Q': {0: "1", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
||||
'R': {0: "2", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
||||
'S': {0: "3", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
||||
'T': {0: "4", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
||||
'U': {0: "5", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
||||
'V': {0: "6", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
||||
'W': {0: "7", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
||||
'X': {0: "8", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
||||
'Y': {0: "9", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
||||
'Z': {0: " ", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
||||
}
|
||||
|
||||
var miceMsgTypes = map[string]string{
|
||||
"000": "Emergency",
|
||||
"001 (Std)": "Priority",
|
||||
"001 (Custom)": "Custom-6",
|
||||
"010 (Std)": "Special",
|
||||
"010 (Custom)": "Custom-5",
|
||||
"011 (Std)": "Committed",
|
||||
"011 (Custom)": "Custom-4",
|
||||
"100 (Std)": "Returning",
|
||||
"100 (Custom)": "Custom-3",
|
||||
"101 (Std)": "In Service",
|
||||
"101 (Custom)": "Custom-2",
|
||||
"110 (Std)": "En Route",
|
||||
"110 (Custom)": "Custom-1",
|
||||
"111 (Std)": "Off Duty",
|
||||
"111 (Custom)": "Custom-0",
|
||||
}
|
||||
219
protocol/aprs/_attic/position.go
Normal file
219
protocol/aprs/_attic/position.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package aprs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLatitude = errors.New("aprs: invalid latitude")
|
||||
ErrLongitude = errors.New("aprs: invalid longitude")
|
||||
)
|
||||
|
||||
// Latitude is the north-south position. Positive values are North, negative are South.
|
||||
type Latitude float64
|
||||
|
||||
func LatitudeFromDMH(degrees, minutes, hundreths int, north bool) Latitude {
|
||||
v := float64(degrees) + float64(minutes)/60 + float64(hundreths)/6000
|
||||
|
||||
for v > 90 {
|
||||
v -= 180
|
||||
}
|
||||
for v < -90 {
|
||||
v += 180
|
||||
}
|
||||
|
||||
if north {
|
||||
return Latitude(v)
|
||||
}
|
||||
return -Latitude(v)
|
||||
}
|
||||
|
||||
func (lat Latitude) DMH() (degrees, minutes, hundreths int, north bool) {
|
||||
degrees = int(lat)
|
||||
minutes = int((float64(lat) - float64(degrees)) * 60)
|
||||
hundreths = int((float64(lat) - float64(degrees) - float64(minutes)/60) * 6000)
|
||||
|
||||
if hundreths == 100 {
|
||||
hundreths = 0
|
||||
minutes += 1
|
||||
}
|
||||
|
||||
if minutes == 60 {
|
||||
minutes = 0
|
||||
degrees += 1
|
||||
}
|
||||
|
||||
north = lat >= 0
|
||||
return
|
||||
}
|
||||
|
||||
func (lat *Latitude) ParseCompressed(b []byte) error {
|
||||
if len(b) != 4 {
|
||||
return ErrLatitude
|
||||
}
|
||||
|
||||
n, err := base91Decode(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*lat = Latitude(90 - float64(n)/380926)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (lat *Latitude) ParseUncompressed(b []byte) error {
|
||||
if len(b) != 8 || b[4] != '.' {
|
||||
return ErrLatitude
|
||||
}
|
||||
|
||||
var north bool
|
||||
switch b[7] {
|
||||
case 'N':
|
||||
north = true
|
||||
case 'S':
|
||||
north = false
|
||||
default:
|
||||
return ErrLatitude
|
||||
}
|
||||
|
||||
var (
|
||||
degrees, minutes, hundreths int
|
||||
err error
|
||||
)
|
||||
if degrees, err = parseBytesWithSpaces(b[0:2]); err != nil {
|
||||
return err
|
||||
}
|
||||
if minutes, err = parseBytesWithSpaces(b[2:4]); err != nil {
|
||||
return err
|
||||
}
|
||||
if hundreths, err = parseBytesWithSpaces(b[5:7]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*lat = LatitudeFromDMH(degrees, minutes, hundreths, north)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (lat Latitude) Compressed(b []byte) {
|
||||
v := int((90 - float64(lat)) * 380926.0)
|
||||
base91Encode(b, v)
|
||||
}
|
||||
|
||||
func (lat Latitude) Uncompressed(b []byte) {
|
||||
var (
|
||||
degrees, minutes, hundreths, north = lat.DMH()
|
||||
v = fmt.Sprintf("%02d%02d.%02d", degrees, minutes, hundreths)
|
||||
)
|
||||
if north {
|
||||
v += "N"
|
||||
} else {
|
||||
v += "S"
|
||||
}
|
||||
copy(b, []byte(b))
|
||||
}
|
||||
|
||||
// Longitude is the east-west position. Positive values are East, negative are West.
|
||||
type Longitude float64
|
||||
|
||||
func LongitudeFromDMH(degrees, minutes, hundreths int, east bool) Longitude {
|
||||
v := float64(degrees) + float64(minutes)/60 + float64(hundreths)/6000
|
||||
|
||||
for v > 180 {
|
||||
v -= 360
|
||||
}
|
||||
for v < -180 {
|
||||
v += 360
|
||||
}
|
||||
|
||||
if east {
|
||||
return Longitude(v)
|
||||
}
|
||||
return -Longitude(v)
|
||||
}
|
||||
|
||||
func (long Longitude) DMH() (degrees, minutes, hundreths int, east bool) {
|
||||
degrees = int(long)
|
||||
minutes = int((float64(long) - float64(degrees)) * 60)
|
||||
hundreths = int((float64(long) - float64(degrees) - float64(minutes)/60) * 6000)
|
||||
|
||||
if hundreths == 100 {
|
||||
hundreths = 0
|
||||
minutes += 1
|
||||
}
|
||||
|
||||
if minutes == 60 {
|
||||
minutes = 0
|
||||
degrees += 1
|
||||
}
|
||||
|
||||
east = long >= 0
|
||||
return
|
||||
}
|
||||
|
||||
func (long *Longitude) ParseCompressed(b []byte) error {
|
||||
if len(b) != 4 {
|
||||
return ErrLatitude
|
||||
}
|
||||
|
||||
n, err := base91Decode(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*long = Longitude(float64(n)/190463.0 - 180)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (long *Longitude) ParseUncompressed(b []byte) error {
|
||||
if len(b) != 9 || b[5] != '.' {
|
||||
return ErrLongitude
|
||||
}
|
||||
|
||||
var east bool
|
||||
switch b[8] {
|
||||
case 'E':
|
||||
east = true
|
||||
case 'W':
|
||||
east = false
|
||||
default:
|
||||
return ErrLongitude
|
||||
}
|
||||
|
||||
var (
|
||||
degrees, minutes, hundreths int
|
||||
err error
|
||||
)
|
||||
if degrees, err = parseBytesWithSpaces(b[0:3]); err != nil {
|
||||
return err
|
||||
}
|
||||
if minutes, err = parseBytesWithSpaces(b[3:5]); err != nil {
|
||||
return err
|
||||
}
|
||||
if hundreths, err = parseBytesWithSpaces(b[6:8]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*long = LongitudeFromDMH(degrees, minutes, hundreths, east)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (long Longitude) Compressed(b []byte) {
|
||||
v := int((180 + float64(long)) * 190463)
|
||||
base91Encode(b, v)
|
||||
}
|
||||
|
||||
func (long Longitude) Uncompressed(b []byte) {
|
||||
var (
|
||||
degrees, minutes, hundreths, east = long.DMH()
|
||||
v = fmt.Sprintf("%03d%02d.%02d", degrees, minutes, hundreths)
|
||||
)
|
||||
if east {
|
||||
v += "E"
|
||||
} else {
|
||||
v += "W"
|
||||
}
|
||||
copy(b, []byte(b))
|
||||
}
|
||||
53
protocol/aprs/_attic/position_test.go
Normal file
53
protocol/aprs/_attic/position_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package aprs
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLatitude(t *testing.T) {
|
||||
tests := []struct {
|
||||
Test string
|
||||
Want Latitude
|
||||
}{
|
||||
{"4903.50N", 49.05833333333333},
|
||||
{"4903.50S", -49.05833333333333},
|
||||
{"4903.5 S", -49.05833333333333},
|
||||
{"4903. S", -49.05},
|
||||
{"490 . S", -49},
|
||||
{"4 . S", -40},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.Test, func(t *testing.T) {
|
||||
var lat Latitude
|
||||
if err := lat.ParseUncompressed([]byte(test.Test)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !testAlmostEqual(float64(test.Want), float64(lat)) {
|
||||
t.Errorf("expected %f, got %f", test.Want, lat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongitude(t *testing.T) {
|
||||
tests := []struct {
|
||||
Test string
|
||||
Want Longitude
|
||||
}{
|
||||
{"00000.00E", 0},
|
||||
{"00000.00W", 0},
|
||||
{"00000.98W", -0.016333},
|
||||
{"00098. W", -1.633333},
|
||||
{"098 . W", -98.000000},
|
||||
{"9 . W", -180.000000},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.Test, func(t *testing.T) {
|
||||
var long Longitude
|
||||
if err := long.ParseUncompressed([]byte(test.Test)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !testAlmostEqual(float64(test.Want), float64(long)) {
|
||||
t.Errorf("expected %f, got %f", test.Want, long)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user