szczanieckiej.git

commit 6cf93ba9b62962fe2c2239fb9c77e779c79a8177

Author: Adam Evyčędo <git@apiote.xyz>

convert berlin

 api/api.go | 297 +++++++++++++++++++++++++++++++++++++++++++++
 api/structs_gen.go | 141 ++++++++++++++++++++
 server/router.go | 12 
 traffic/berlin_vbb.go | 224 +++++++++++++++++++++++++++++++++
 traffic/convert.go | 12 +
 traffic/feeds.go | 1 
 transformers/de.go | 29 ++++
 transformers/fr.go | 21 +++


diff --git a/api/api.go b/api/api.go
index 3abdd769edfd177d94bc6bde1087d9ee3a742d44..2931d8d64c2c244d598c2f70f734d2a66d486439 100644
--- a/api/api.go
+++ b/api/api.go
@@ -109,6 +109,39 @@
 	return line, nil
 }
 
+func convertTrafficLineGraphsV1forLineV2(trafficLine traffic.Line, line LineV2, context traffic.Context, t *traffic.Traffic) (LineV2, error) {
+	if len(trafficLine.GraphThere.StopCodes) != 0 {
+		graph := LineGraphV1{
+			Stops:     make([]StopStubV1, len(trafficLine.GraphThere.StopCodes)),
+			NextNodes: trafficLine.GraphThere.NextNodes,
+		}
+		for i, code := range trafficLine.GraphThere.StopCodes {
+			stopStub, err := traffic.GetStopStub(code, line.Name, context, t)
+			if err != nil {
+				return line, fmt.Errorf("while getting stopStub for %s: %w", code, err)
+			}
+			graph.Stops[i] = convertTrafficStopStub(stopStub)
+		}
+		line.Graphs = append(line.Graphs, graph)
+	}
+	if len(trafficLine.GraphBack.StopCodes) != 0 {
+		graph := LineGraphV1{
+			Stops:     make([]StopStubV1, len(trafficLine.GraphBack.StopCodes)),
+			NextNodes: trafficLine.GraphBack.NextNodes,
+		}
+		for i, code := range trafficLine.GraphBack.StopCodes {
+			stopStub, err := traffic.GetStopStub(code, line.Name, context, t)
+			if err != nil {
+				return line, fmt.Errorf("while getting stopStub for %s: %w", code, err)
+			}
+			graph.Stops[i] = convertTrafficStopStub(stopStub)
+		}
+		line.Graphs = append(line.Graphs, graph)
+	}
+
+	return line, nil
+}
+
 func convertTrafficLine(line traffic.Line, feedID string) LineV1 {
 	l := LineV1{
 		Name:      line.Name,
@@ -127,6 +160,24 @@ 	}
 	return l
 }
 
+func convertTrafficLineV2(line traffic.Line, feedID string) LineV2 {
+	l := LineV2{
+		Name:      line.Name,
+		Colour:    fromColor(line.Colour),
+		Kind:      makeLineTypeV3(line),
+		FeedID:    feedID,
+		Headsigns: [][]string{},
+	}
+
+	if len(line.HeadsignsThere) != 0 {
+		l.Headsigns = append(l.Headsigns, line.HeadsignsThere)
+	}
+	if len(line.HeadsignsBack) != 0 {
+		l.Headsigns = append(l.Headsigns, line.HeadsignsBack)
+	}
+	return l
+}
+
 func convertTrafficVehicle(vehicle traffic.VehicleStatus, context traffic.Context, t *traffic.Traffic) (VehicleV1, error) {
 	line, err := traffic.GetLine(vehicle.LineName, context, t)
 	if err != nil {
@@ -159,6 +210,26 @@ 		Position:     PositionV1{vehicle.Position.Lat, vehicle.Position.Lon},
 		Capabilities: vehicle.Capabilities,
 		Speed:        vehicle.Speed,
 		Line:         LineStubV2{Name: line.Name, Kind: makeLineTypeV2(line), Colour: fromColor(line.Colour)},
+		Headsign:     vehicle.Headsign,
+		// todo CongestionLevel
+		// todo OccupancyStatus
+		// todo Status
+		// todo Delay
+	}, nil
+}
+
+func convertTrafficVehicleV3(vehicle traffic.VehicleStatus, context traffic.Context, t *traffic.Traffic) (VehicleV3, error) {
+	line, err := traffic.GetLine(vehicle.LineName, context, t)
+	if err != nil {
+		return VehicleV3{}, fmt.Errorf("while getting line %s: %w", vehicle.LineName, err)
+	}
+	log.Printf("convertTrafficVehicle:: trafficVehicle: %+v, line: %+v\n", vehicle, line)
+	return VehicleV3{
+		Id:           string(vehicle.Id),
+		Position:     PositionV1{vehicle.Position.Lat, vehicle.Position.Lon},
+		Capabilities: vehicle.Capabilities,
+		Speed:        vehicle.Speed,
+		Line:         LineStubV3{Name: line.Name, Kind: makeLineTypeV3(line), Colour: fromColor(line.Colour)},
 		Headsign:     vehicle.Headsign,
 		// todo CongestionLevel
 		// todo OccupancyStatus
@@ -265,6 +336,30 @@ 	success.Queryables = sortQueryables(query, success.Queryables)
 	return success, nil
 }
 
+func CreateSuccessQueryablesV3(query string, items []traffic.Item, context traffic.Context, t *traffic.Traffic, other QueryablesResponse) (QueryablesResponse, error) {
+	success := QueryablesResponseDev{
+		Queryables: []QueryableV3{},
+	}
+	for _, item := range items {
+		if stop, ok := item.(traffic.Stop); ok {
+			s := convertTrafficStopV2(stop, context.FeedName)
+			success.Queryables = append(success.Queryables, QueryableV3(s))
+		} else if line, ok := item.(traffic.Line); ok {
+			l := convertTrafficLineV2(line, context.FeedName)
+			success.Queryables = append(success.Queryables, QueryableV3(l))
+		} else {
+			// todo error
+		}
+	}
+	if otherV3, ok := other.(QueryablesResponseDev); ok {
+		success.Queryables = append(success.Queryables, otherV3.Queryables...)
+	} else {
+		return success, errors.New("wrong version of other")
+	}
+	success.Queryables = sortQueryablesV3(query, success.Queryables)
+	return success, nil
+}
+
 func LimitQueryables(r QueryablesResponse, offset, limit uint64) QueryablesResponse {
 	var result QueryablesResponse
 
@@ -289,6 +384,16 @@ 		} else {
 			queryables = r.(QueryablesResponseV2).Queryables[offset : offset+limit]
 		}
 		result = QueryablesResponseV2{queryables}
+	case QueryablesResponseDev:
+		var queryables []QueryableV3
+		if int(offset) > len(r.(QueryablesResponseDev).Queryables) {
+			queryables = []QueryableV3{}
+		} else if len(r.(QueryablesResponseDev).Queryables) < int(offset+limit) {
+			queryables = r.(QueryablesResponseDev).Queryables[offset:]
+		} else {
+			queryables = r.(QueryablesResponseDev).Queryables[offset : offset+limit]
+		}
+		result = QueryablesResponseDev{queryables}
 
 	}
 
@@ -325,6 +430,36 @@ 	})
 	return queryables
 }
 
+func sortQueryablesV3(query string, queryables []QueryableV3) []QueryableV3 {
+	// fixme query and names should be cleaned
+	sort.Slice(queryables, func(i, j int) bool {
+		var nameI, nameJ string
+
+		switch queryables[i].(type) {
+		case StopV2:
+			nameI = queryables[i].(StopV2).Name
+		case LineV2:
+			nameI = queryables[i].(LineV2).Name
+		}
+		switch queryables[j].(type) {
+		case StopV2:
+			nameJ = queryables[j].(StopV2).Name
+		case LineV2:
+			nameJ = queryables[j].(LineV2).Name
+		}
+		levenshtein := &metrics.Levenshtein{
+			CaseSensitive: false,
+			InsertCost:    1,
+			DeleteCost:    1,
+			ReplaceCost:   1,
+		}
+		distance1 := strutil.Similarity(query, nameI, levenshtein)
+		distance2 := strutil.Similarity(query, nameJ, levenshtein)
+		return distance1 > distance2
+	})
+	return queryables
+}
+
 func CreateSuccessLocatables(locatables []traffic.Locatable, context traffic.Context, t *traffic.Traffic, other LocatablesResponse) (LocatablesResponse, error) {
 	success := LocatablesResponseV1{
 		Locatables: []LocatableV1{},
@@ -373,6 +508,30 @@ 	}
 	return success, nil
 }
 
+func CreateSuccessLocatablesV3(locatables []traffic.Locatable, context traffic.Context, t *traffic.Traffic, other LocatablesResponse) (LocatablesResponse, error) {
+	success := LocatablesResponseDev{
+		Locatables: []LocatableV3{},
+	}
+	for _, locatable := range locatables {
+		if stop, ok := locatable.(traffic.Stop); ok {
+			s := convertTrafficStopV2(stop, context.FeedName)
+			success.Locatables = append(success.Locatables, LocatableV3(s))
+		} else if vehicle, ok := locatable.(traffic.VehicleStatus); ok {
+			v, err := convertTrafficVehicleV3(vehicle, context, t)
+			if err != nil {
+				return success, fmt.Errorf("while converting Traffic Vehicle %s: %w", vehicle.Id, err)
+			}
+			success.Locatables = append(success.Locatables, LocatableV3(v))
+		}
+	}
+	if otherV3, ok := other.(LocatablesResponseDev); ok {
+		success.Locatables = append(success.Locatables, otherV3.Locatables...)
+	} else {
+		return success, errors.New("wrong version of other")
+	}
+	return success, nil
+}
+
 func CreateSuccessLine(line traffic.Line, context traffic.Context, t *traffic.Traffic) (LineResponse, error) {
 	l := convertTrafficLine(line, context.FeedName)
 	l, err := convertTrafficLineGraphs(line, l, context, t)
@@ -386,6 +545,19 @@
 	return success, nil
 }
 
+func CreateSuccessLineV2(line traffic.Line, context traffic.Context, t *traffic.Traffic) (LineResponse, error) {
+	l := convertTrafficLineV2(line, context.FeedName)
+	l, err := convertTrafficLineGraphsV1forLineV2(line, l, context, t)
+	if err != nil {
+		return LineResponseDev{}, fmt.Errorf("while converting graph: %w", err)
+	}
+	success := LineResponseDev{
+		Line: l,
+	}
+
+	return success, nil
+}
+
 func convertVehicle(update gtfs_rt.Update, vehicles map[string]traffic.Vehicle, line traffic.Line, headsign string) VehicleV1 {
 	return VehicleV1{
 		Id:           update.VehicleID,
@@ -406,6 +578,19 @@ 		Position:     PositionV1{Lat: float64(update.Latitude), Lon: float64(update.Longitude)},
 		Capabilities: vehicles[update.VehicleID].Capabilities,
 		Speed:        update.Speed,
 		Line:         LineStubV2{Name: line.Name, Kind: makeLineTypeV2(line), Colour: fromColor(line.Colour)},
+		Headsign:     headsign,
+		// todo CongestionLevel
+		// todo OccupancyStatus
+	}
+}
+
+func convertVehicleV3(update gtfs_rt.Update, vehicles map[string]traffic.Vehicle, line traffic.Line, headsign string) VehicleV3 {
+	return VehicleV3{
+		Id:           update.VehicleID,
+		Position:     PositionV1{Lat: float64(update.Latitude), Lon: float64(update.Longitude)},
+		Capabilities: vehicles[update.VehicleID].Capabilities,
+		Speed:        update.Speed,
+		Line:         LineStubV3{Name: line.Name, Kind: makeLineTypeV3(line), Colour: fromColor(line.Colour)},
 		Headsign:     headsign,
 		// todo CongestionLevel
 		// todo OccupancyStatus
@@ -582,6 +767,91 @@ 	}
 	return success, nil
 }
 
+func CreateSuccessDeparturesV3(stop traffic.Stop, departures []traffic.DepartureRealtime,
+	date time.Time, vehicles map[string]traffic.Vehicle, alerts []traffic.Alert, ctx traffic.Context, t *traffic.Traffic, accept uint) (DeparturesResponse, error) {
+	d := []DepartureV3{}
+	var success DeparturesResponse
+	now := time.Now()
+	timezone, err := traffic.GetTimezone(stop, t.Feeds[ctx.FeedName])
+	if err != nil {
+		return success, err
+	}
+	datetime := time.Date(date.Year(), date.Month(),
+		date.Day(), now.Hour(), now.Minute(), now.Second(), 0, timezone)
+	for _, trafficDeparture := range departures {
+		zoneAbbr := trafficDeparture.Update.Time.Location().String()
+		stopOrder, err := marshalStopOrder(trafficDeparture.Order.TripOffset, trafficDeparture.Order.Sequence)
+		if err != nil {
+			return success, err
+		}
+		line, err := traffic.GetLine(trafficDeparture.LineName, ctx, t)
+		if err != nil {
+			return success, fmt.Errorf("while getting line %s: %w", trafficDeparture.LineName, err)
+		}
+		departure := DepartureV3{
+			Id: stopOrder,
+			Time: TimeV1{
+				DayOffset: getDayOffset(datetime, trafficDeparture.Update.Time),
+				Hour:      uint8(trafficDeparture.Update.Time.Hour()),
+				Minute:    uint8(trafficDeparture.Update.Time.Minute()),
+				Second:    uint8(trafficDeparture.Update.Time.Second()),
+				Zone:      zoneAbbr,
+			},
+			Status:     STATUS_IN_TRANSIT,
+			IsRealtime: trafficDeparture.Update.TripUpdate != nil,
+			Vehicle:    convertVehicleV3(trafficDeparture.Update, vehicles, line, trafficDeparture.Headsign),
+			Boarding:   makeBoardingV1(trafficDeparture.Departure.Pickup, trafficDeparture.Departure.Dropoff),
+		}
+		timeToArrival := trafficDeparture.Update.Time.Sub(datetime).Minutes()
+		if departure.IsRealtime {
+			if trafficDeparture.Update.Status == pb.VehiclePosition_STOPPED_AT {
+				departure.Status = STATUS_AT_STOP
+			} else if timeToArrival < 0 {
+				departure.Status = STATUS_DEPARTED
+			} else if timeToArrival < 1 {
+				departure.Status = STATUS_INCOMING
+			}
+
+			if trafficDeparture.Update.CongestionLevel == nil {
+				departure.Vehicle.CongestionLevel = CONGESTION_UNKNOWN
+			} else if *trafficDeparture.Update.CongestionLevel == pb.VehiclePosition_RUNNING_SMOOTHLY {
+				departure.Vehicle.CongestionLevel = CONGESTION_SMOOTH
+			} else if *trafficDeparture.Update.CongestionLevel == pb.VehiclePosition_STOP_AND_GO {
+				departure.Vehicle.CongestionLevel = CONGESTION_STOP_AND_GO
+			} else if *trafficDeparture.Update.CongestionLevel == pb.VehiclePosition_CONGESTION {
+				departure.Vehicle.CongestionLevel = CONGESTION_SIGNIFICANT
+			} else if *trafficDeparture.Update.CongestionLevel == pb.VehiclePosition_SEVERE_CONGESTION {
+				departure.Vehicle.CongestionLevel = CONGESTION_SEVERE
+			}
+
+			if trafficDeparture.Update.OccupancyStatus == nil {
+				departure.Vehicle.OccupancyStatus = OCCUPANCY_UNKNOWN
+			} else if *trafficDeparture.Update.OccupancyStatus == pb.VehiclePosition_EMPTY {
+				departure.Vehicle.OccupancyStatus = OCCUPANCY_EMPTY
+			} else if *trafficDeparture.Update.OccupancyStatus == pb.VehiclePosition_MANY_SEATS_AVAILABLE {
+				departure.Vehicle.OccupancyStatus = OCCUPANCY_MANY_AVAILABLE
+			} else if *trafficDeparture.Update.OccupancyStatus == pb.VehiclePosition_FEW_SEATS_AVAILABLE {
+				departure.Vehicle.OccupancyStatus = OCCUPANCY_FEW_AVAILABLE
+			} else if *trafficDeparture.Update.OccupancyStatus == pb.VehiclePosition_STANDING_ROOM_ONLY {
+				departure.Vehicle.OccupancyStatus = OCCUPANCY_STANDING_ONLY
+			} else if *trafficDeparture.Update.OccupancyStatus == pb.VehiclePosition_CRUSHED_STANDING_ROOM_ONLY {
+				departure.Vehicle.OccupancyStatus = OCCUPANCY_CRUSHED
+			} else if *trafficDeparture.Update.OccupancyStatus == pb.VehiclePosition_FULL {
+				departure.Vehicle.OccupancyStatus = OCCUPANCY_FULL
+			} else if *trafficDeparture.Update.OccupancyStatus == pb.VehiclePosition_NOT_ACCEPTING_PASSENGERS {
+				departure.Vehicle.OccupancyStatus = OCCUPANCY_NOT_ACCEPTING
+			}
+		}
+		d = append(d, departure)
+	}
+	success = DeparturesResponseDev{
+		Stop:       convertTrafficStopV2(stop, ctx.FeedName),
+		Departures: d,
+		Alerts:     convertTrafficAlerts(alerts),
+	}
+	return success, nil
+}
+
 func fromColor(c traffic.Colour) ColourV1 {
 	return ColourV1{
 		R: c.R,
@@ -609,6 +879,33 @@ 	} else if line.Kind == traffic.TROLLEYBUS {
 		return LINE_V2_TROLLEYBUS
 	} else {
 		return LINE_V2_UNKNOWN
+	}
+}
+
+func makeLineTypeV3(line traffic.Line) LineTypeV3 {
+	switch line.Kind {
+	case traffic.TRAM:
+		return LINE_V3_TRAM
+	case traffic.BUS:
+		return LINE_V3_BUS
+	case traffic.TROLLEYBUS:
+		return LINE_V3_TROLLEYBUS
+	case traffic.METRO:
+		return LINE_V3_METRO
+	case traffic.RAIL:
+		return LINE_V3_RAIL
+	case traffic.FERRY:
+		return LINE_V3_FERRY
+	case traffic.CABLE_TRAM:
+		return LINE_V3_CABLE_TRAM
+	case traffic.CABLE_CAR:
+		return LINE_V3_CABLE_CAR
+	case traffic.FUNICULAR:
+		return LINE_V3_FUNICULAR
+	case traffic.MONORAIL:
+		return LINE_V3_MONORAIL
+	default:
+		return LINE_V3_UNKNOWN
 	}
 }
 




diff --git a/api/structs_gen.go b/api/structs_gen.go
index 66e546b0bf55c30718aeaad52aa08abc145f03d6..ed9bb898e15e05874302e98b911d8afea5674d03 100644
--- a/api/structs_gen.go
+++ b/api/structs_gen.go
@@ -8,7 +8,7 @@ 	"git.sr.ht/~sircmpwn/go-bare"
 )
 
 type LineResponseDev struct {
-	Line LineV1 `bare:"line"`
+	Line LineV2 `bare:"line"`
 }
 
 func (t *LineResponseDev) Decode(data []byte) error {
@@ -72,7 +72,7 @@ 	return bare.Marshal(t)
 }
 
 type QueryablesResponseDev struct {
-	Queryables []QueryableV2 `bare:"queryables"`
+	Queryables []QueryableV3 `bare:"queryables"`
 }
 
 func (t *QueryablesResponseDev) Decode(data []byte) error {
@@ -158,6 +158,23 @@ func (t *LineV1) Encode() ([]byte, error) {
 	return bare.Marshal(t)
 }
 
+type LineV2 struct {
+	Name      string        `bare:"name"`
+	Colour    ColourV1      `bare:"colour"`
+	Kind      LineTypeV3    `bare:"kind"`
+	FeedID    string        `bare:"feedID"`
+	Headsigns [][]string    `bare:"headsigns"`
+	Graphs    []LineGraphV1 `bare:"graphs"`
+}
+
+func (t *LineV2) Decode(data []byte) error {
+	return bare.Unmarshal(data, t)
+}
+
+func (t *LineV2) Encode() ([]byte, error) {
+	return bare.Marshal(t)
+}
+
 type LineGraphV1 struct {
 	Stops     []StopStubV1  `bare:"stops"`
 	NextNodes map[int][]int `bare:"nextNodes"`
@@ -214,7 +231,7 @@ 	return bare.Marshal(t)
 }
 
 type LocatablesResponseDev struct {
-	Locatables []LocatableV2 `bare:"locatables"`
+	Locatables []LocatableV3 `bare:"locatables"`
 }
 
 func (t *LocatablesResponseDev) Decode(data []byte) error {
@@ -287,6 +304,25 @@ func (t *VehicleV2) Encode() ([]byte, error) {
 	return bare.Marshal(t)
 }
 
+type VehicleV3 struct {
+	Id              string            `bare:"id"`
+	Position        PositionV1        `bare:"position"`
+	Capabilities    uint16            `bare:"capabilities"`
+	Speed           float32           `bare:"speed"`
+	Line            LineStubV3        `bare:"line"`
+	Headsign        string            `bare:"headsign"`
+	CongestionLevel CongestionLevelV1 `bare:"congestionLevel"`
+	OccupancyStatus OccupancyStatusV1 `bare:"occupancyStatus"`
+}
+
+func (t *VehicleV3) Decode(data []byte) error {
+	return bare.Unmarshal(data, t)
+}
+
+func (t *VehicleV3) Encode() ([]byte, error) {
+	return bare.Marshal(t)
+}
+
 type LineStubV1 struct {
 	Name   string     `bare:"name"`
 	Kind   LineTypeV1 `bare:"kind"`
@@ -315,6 +351,20 @@ func (t *LineStubV2) Encode() ([]byte, error) {
 	return bare.Marshal(t)
 }
 
+type LineStubV3 struct {
+	Name   string     `bare:"name"`
+	Kind   LineTypeV3 `bare:"kind"`
+	Colour ColourV1   `bare:"colour"`
+}
+
+func (t *LineStubV3) Decode(data []byte) error {
+	return bare.Unmarshal(data, t)
+}
+
+func (t *LineStubV3) Encode() ([]byte, error) {
+	return bare.Marshal(t)
+}
+
 type ColourV1 struct {
 	R uint8 `bare:"r"`
 	G uint8 `bare:"g"`
@@ -331,7 +381,7 @@ }
 
 type DeparturesResponseDev struct {
 	Alerts     []AlertV1     `bare:"alerts"`
-	Departures []DepartureV2 `bare:"departures"`
+	Departures []DepartureV3 `bare:"departures"`
 	Stop       StopV2        `bare:"stop"`
 }
 
@@ -421,6 +471,23 @@ func (t *DepartureV2) Encode() ([]byte, error) {
 	return bare.Marshal(t)
 }
 
+type DepartureV3 struct {
+	Id         string          `bare:"id"`
+	Time       TimeV1          `bare:"time"`
+	Status     VehicleStatusV1 `bare:"status"`
+	IsRealtime bool            `bare:"isRealtime"`
+	Vehicle    VehicleV3       `bare:"vehicle"`
+	Boarding   uint8           `bare:"boarding"`
+}
+
+func (t *DepartureV3) Decode(data []byte) error {
+	return bare.Unmarshal(data, t)
+}
+
+func (t *DepartureV3) Encode() ([]byte, error) {
+	return bare.Marshal(t)
+}
+
 type TimeV1 struct {
 	Hour      uint8  `bare:"hour"`
 	Minute    uint8  `bare:"minute"`
@@ -554,6 +621,50 @@ 	}
 	panic(errors.New("Invalid LineTypeV2 value"))
 }
 
+type LineTypeV3 uint
+
+const (
+	LINE_V3_UNKNOWN    LineTypeV3 = 0
+	LINE_V3_TRAM       LineTypeV3 = 1
+	LINE_V3_BUS        LineTypeV3 = 2
+	LINE_V3_TROLLEYBUS LineTypeV3 = 3
+	LINE_V3_METRO      LineTypeV3 = 4
+	LINE_V3_RAIL       LineTypeV3 = 5
+	LINE_V3_FERRY      LineTypeV3 = 6
+	LINE_V3_CABLE_TRAM LineTypeV3 = 7
+	LINE_V3_CABLE_CAR  LineTypeV3 = 8
+	LINE_V3_FUNICULAR  LineTypeV3 = 9
+	LINE_V3_MONORAIL   LineTypeV3 = 10
+)
+
+func (t LineTypeV3) String() string {
+	switch t {
+	case LINE_V3_UNKNOWN:
+		return "LINE_V3_UNKNOWN"
+	case LINE_V3_TRAM:
+		return "LINE_V3_TRAM"
+	case LINE_V3_BUS:
+		return "LINE_V3_BUS"
+	case LINE_V3_TROLLEYBUS:
+		return "LINE_V3_TROLLEYBUS"
+	case LINE_V3_METRO:
+		return "LINE_V3_METRO"
+	case LINE_V3_RAIL:
+		return "LINE_V3_RAIL"
+	case LINE_V3_FERRY:
+		return "LINE_V3_FERRY"
+	case LINE_V3_CABLE_TRAM:
+		return "LINE_V3_CABLE_TRAM"
+	case LINE_V3_CABLE_CAR:
+		return "LINE_V3_CABLE_CAR"
+	case LINE_V3_FUNICULAR:
+		return "LINE_V3_FUNICULAR"
+	case LINE_V3_MONORAIL:
+		return "LINE_V3_MONORAIL"
+	}
+	panic(errors.New("Invalid LineTypeV3 value"))
+}
+
 type AlertCauseV1 uint
 
 const (
@@ -740,6 +851,13 @@ func (_ StopV2) IsUnion() {}
 
 func (_ LineV1) IsUnion() {}
 
+type QueryableV3 interface {
+	bare.Union
+}
+
+
+func (_ LineV2) IsUnion() {}
+
 type LocatablesResponse interface {
 	bare.Union
 }
@@ -764,6 +882,13 @@
 
 func (_ VehicleV2) IsUnion() {}
 
+type LocatableV3 interface {
+	bare.Union
+}
+
+
+func (_ VehicleV3) IsUnion() {}
+
 type DeparturesResponse interface {
 	bare.Union
 }
@@ -795,6 +920,10 @@ 	bare.RegisterUnion((*QueryableV2)(nil)).
 		Member(*new(StopV2), 0).
 		Member(*new(LineV1), 1)
 
+	bare.RegisterUnion((*QueryableV3)(nil)).
+		Member(*new(StopV2), 0).
+		Member(*new(LineV2), 1)
+
 	bare.RegisterUnion((*LocatablesResponse)(nil)).
 		Member(*new(LocatablesResponseDev), 0).
 		Member(*new(LocatablesResponseV1), 1).
@@ -807,6 +936,10 @@
 	bare.RegisterUnion((*LocatableV2)(nil)).
 		Member(*new(StopV2), 0).
 		Member(*new(VehicleV2), 1)
+
+	bare.RegisterUnion((*LocatableV3)(nil)).
+		Member(*new(StopV2), 0).
+		Member(*new(VehicleV3), 1)
 
 	bare.RegisterUnion((*DeparturesResponse)(nil)).
 		Member(*new(DeparturesResponseDev), 0).




diff --git a/server/router.go b/server/router.go
index 5873f48d16c1dff0e8a5306356e60d9bbf84ff8b..69fb052922a1d6c99b0d2f7d8b38d413774f9379 100644
--- a/server/router.go
+++ b/server/router.go
@@ -253,7 +253,7 @@ 			locatables = append(locatables, vehicle)
 		}
 		switch accept {
 		case 0:
-			locatablesSuccess, err = api.CreateSuccessLocatablesV2(locatables, context, t, locatablesSuccess)
+			locatablesSuccess, err = api.CreateSuccessLocatablesV3(locatables, context, t, locatablesSuccess)
 		case 1:
 			locatablesSuccess, err = api.CreateSuccessLocatables(locatables, context, t, locatablesSuccess)
 		case 2:
@@ -353,7 +353,7 @@ 				items = append(items, stop)
 			}
 			switch accept {
 			case 0:
-				queryablesSuccess, err = api.CreateSuccessQueryablesV2(query, items, context, t, queryablesSuccess)
+				queryablesSuccess, err = api.CreateSuccessQueryablesV3(query, items, context, t, queryablesSuccess)
 			case 1:
 				queryablesSuccess, err = api.CreateSuccessQueryables(items, context, t, queryablesSuccess)
 			case 2:
@@ -373,7 +373,7 @@ 					return fmt.Errorf("while getting stop: %w", err)
 				}
 				switch accept {
 				case 0:
-					queryablesSuccess, err = api.CreateSuccessQueryablesV2(query, []traffic.Item{stop}, context, t, queryablesSuccess)
+					queryablesSuccess, err = api.CreateSuccessQueryablesV3(query, []traffic.Item{stop}, context, t, queryablesSuccess)
 				case 1:
 					queryablesSuccess, err = api.CreateSuccessQueryables([]traffic.Item{stop}, context, t, queryablesSuccess)
 				case 2:
@@ -402,7 +402,7 @@ 				}
 
 				switch accept {
 				case 0:
-					queryablesSuccess, err = api.CreateSuccessQueryablesV2(query, items, context, t, queryablesSuccess)
+					queryablesSuccess, err = api.CreateSuccessQueryablesV3(query, items, context, t, queryablesSuccess)
 				case 1:
 					queryablesSuccess, err = api.CreateSuccessQueryables(items, context, t, queryablesSuccess)
 				case 2:
@@ -550,7 +550,7 @@
 	var success api.DeparturesResponse
 	switch accept {
 	case 0:
-		success, err = api.CreateSuccessDeparturesV2(stop, departures, date, t.Vehicles[feedName][versionCode], alerts, context, t, accept)
+		success, err = api.CreateSuccessDeparturesV3(stop, departures, date, t.Vehicles[feedName][versionCode], alerts, context, t, accept)
 	case 1:
 		success, err = api.CreateSuccessDeparturesV1(stop, departures, date, t.Vehicles[feedName][versionCode], alerts, context, t, accept)
 	case 2:
@@ -653,7 +653,7 @@ 		}
 		var success api.LineResponse
 		switch accept {
 		case 0:
-			success, err = api.CreateSuccessLine(line, context, t)
+			success, err = api.CreateSuccessLineV2(line, context, t)
 		case 1:
 			success, err = api.CreateSuccessLine(line, context, t)
 		}




diff --git a/traffic/berlin_vbb.go b/traffic/berlin_vbb.go
new file mode 100644
index 0000000000000000000000000000000000000000..24090b69fc818cf5aac3a6eef86392bff72fd2df
--- /dev/null
+++ b/traffic/berlin_vbb.go
@@ -0,0 +1,224 @@
+package traffic
+
+import (
+	"bufio"
+	"encoding/csv"
+	"io"
+	"path/filepath"
+	"strings"
+
+	"apiote.xyz/p/szczanieckiej/transformers"
+
+	"fmt"
+	"net/http"
+	"os"
+	"time"
+
+	"golang.org/x/text/language"
+	"golang.org/x/text/transform"
+)
+
+type VbbBerlin struct {
+	client http.Client
+}
+
+func (z VbbBerlin) ConvertVehicles(path string) error {
+	result, err := os.Create(filepath.Join(path, "vehicles.bare"))
+	if err != nil {
+		return fmt.Errorf("ConvertVehicles: cannot create bare file: %w", err)
+	}
+	defer result.Close()
+	return nil
+}
+
+func (z VbbBerlin) GetVersions(date time.Time) ([]Version, error) {
+	versions := []Version{}
+	version, err := MakeVersion("00010101_99991231", z.GetLocation())
+	if err != nil {
+		return nil, err
+	}
+	version.Link = "https://www.vbb.de/fileadmin/user_upload/VBB/Dokumente/API-Datensaetze/gtfs-mastscharf/GTFS.zip"
+	versions = append(versions, version)
+	return versions, nil
+}
+
+func (z VbbBerlin) GetLocation() *time.Location {
+	l, _ := time.LoadLocation("Europe/Berlin")
+	return l
+}
+
+func (z VbbBerlin) String() string {
+	return "berlin_vbb"
+}
+
+func (z VbbBerlin) RealtimeFeeds() []string {
+	return []string{}
+}
+
+func (z VbbBerlin) Transformer() transform.Transformer {
+	return transform.Chain(transformers.TransformerDE, transformers.TransformerPL, transformers.TransformerFR)
+}
+
+func (z VbbBerlin) Name() string {
+	return "VBB Berlin"
+}
+
+func (z VbbBerlin) Attribution() map[language.Tag]string {
+	// TODO
+	return map[language.Tag]string{
+		language.Und: "GTFS files downloaded from https://www.vbb.de/vbbgtfs and converted to TRAFFIC",
+	}
+}
+
+func (z VbbBerlin) Description() map[language.Tag]string {
+	return map[language.Tag]string{
+		language.Und:             "Timetable for public transport in German metropolitan region including the city state of Berlin and the surrounding state of Brandenburg organised by Verkehrsverbund Berlin-Brandenburg (VBB). Includes trams, busses, U-Bahn (underground), S-Bahn, ferries, and intercity trains",
+		language.BritishEnglish:  "Timetable for public transport in German metropolitan region including the city state of Berlin and the surrounding state of Brandenburg organised by Verkehrsverbund Berlin-Brandenburg (VBB). Includes trams, busses, U-Bahn (underground), S-Bahn, ferries, and intercity trains",
+		language.AmericanEnglish: "Timetable for public transport in German metropolitan region including the city state of Berlin and the surrounding state of Brandenburg organized by Verkehrsverbund Berlin-Brandenburg (VBB). Includes trams, busses, U-Bahn (subway), S-Bahn, ferries, and intercity trains",
+		language.Polish:          "Rozkład jazdy transportu publicznego w niemieckim rejonie metropolitalnym (Berlin i Brandenburgia) organizowanego przez Verkehrsverbund Berlin-Brandenburg (VBB). Zawiera tramwaje, autobusy, U-Bahn (metro), S-Bahn, promy i koleje międzymiastowe",
+		language.German:          "Fahrplan für den ÖPNV in Berlin und Brandenburg (VBB). Umfasst Straßenbahnen, Busse, U-Bahn, S-Bahn, Fähren und Fernverkehr",
+	}
+}
+
+func (z VbbBerlin) Flags() FeedFlags {
+	return FeedFlags{
+		Headsign:     HeadsignTripHeadsing,
+		StopIdFormat: "{{stop_id}}",
+		StopName:     "{{stop_name}} | {{platform_code}}", // TODO platform_code might not be present, but currently templating doesn’t support this
+		LineName:     "{{route_short_name}}",
+	}
+}
+
+func (z VbbBerlin) FeedPrepareZip(path string) error {
+	// NOTE add platform codes from stop_id
+	stopsFile, err := os.Open(filepath.Join(path, "stops.txt"))
+	if err != nil {
+		return fmt.Errorf("while opening stops file: %w", err)
+	}
+	defer stopsFile.Close()
+	stops2File, err := os.OpenFile(filepath.Join(path, "stops2.txt"), os.O_RDWR|os.O_CREATE, 0644)
+	if err != nil {
+		return fmt.Errorf("while opening stops2 file: %w", err)
+	}
+	defer stops2File.Close()
+	r := csv.NewReader(bufio.NewReader(stopsFile))
+	w := csv.NewWriter(stops2File)
+	header, err := r.Read()
+	if err != nil {
+		return fmt.Errorf("while reading stops header: %w", err)
+	}
+	fields := map[string]int{}
+	for i, headerField := range header {
+		fields[headerField] = i
+	}
+	err = w.Write(header)
+	if err != nil {
+		return fmt.Errorf("while writing stops header: %w", err)
+	}
+	fmt.Println("header", fields)
+	for {
+		record, err := r.Read()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return fmt.Errorf("while reading a stop record: %w", err)
+		}
+		f, ok := fields["location_type"]
+		fmt.Println("FIX stop:", f, ok, "Platform:", record[fields["platform_code"]])
+		if ok {
+			fmt.Println(record[f])
+		}
+		if (!ok || record[f] == "" || record[f] == "0") && record[fields["platform_code"]] == "" {
+			fmt.Println(fields["stop_id"], record[fields["stop_id"]])
+			stopID := strings.Split(record[fields["stop_id"]], ":")
+			fmt.Println("So fixing", len(stopID), stopID)
+			if len(stopID) >= 5 {
+				record[fields["platform_code"]] = stopID[4]
+			}
+		}
+		fmt.Println("write:", record)
+		err = w.Write(record)
+		fmt.Println("NEXT")
+		if err != nil {
+			return fmt.Errorf("while writing a stop record: %w", err)
+		}
+	}
+	w.Flush()
+	err = os.Remove(filepath.Join(path, "stops.txt"))
+	if err != nil {
+		return fmt.Errorf("while removing stops: %w", err)
+	}
+	err = os.Rename(filepath.Join(path, "stops2.txt"), filepath.Join(path, "stops.txt"))
+	if err != nil {
+		return fmt.Errorf("while renaming stops: %w", err)
+	}
+
+	// NOTE fix route types
+	routesFile, err := os.Open(filepath.Join(path, "routes.txt"))
+	if err != nil {
+		return fmt.Errorf("while opening routes file: %w", err)
+	}
+	defer routesFile.Close()
+	routes2File, err := os.OpenFile(filepath.Join(path, "routes2.txt"), os.O_RDWR|os.O_CREATE, 0644)
+	if err != nil {
+		return fmt.Errorf("while opening routes2 file: %w", err)
+	}
+	defer routes2File.Close()
+	r = csv.NewReader(bufio.NewReader(routesFile))
+	w = csv.NewWriter(routes2File)
+	header, err = r.Read()
+	if err != nil {
+		return fmt.Errorf("while reading routes header: %w", err)
+	}
+	fields = map[string]int{}
+	for i, headerField := range header {
+		fields[headerField] = i
+	}
+	err = w.Write(header)
+	if err != nil {
+		return fmt.Errorf("while writing routes header: %w", err)
+	}
+
+	for {
+		record, err := r.Read()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return fmt.Errorf("while reading a route record: %w", err)
+		}
+		if record[fields["route_type"]] == "100" {
+			record[fields["route_type"]] = "2"
+		}
+		if record[fields["route_type"]] == "109" {
+			record[fields["route_type"]] = "0"
+		}
+		if record[fields["route_type"]] == "400" {
+			record[fields["route_type"]] = "1"
+		}
+		if record[fields["route_type"]] == "700" {
+			record[fields["route_type"]] = "3"
+		}
+		if record[fields["route_type"]] == "900" {
+			record[fields["route_type"]] = "0"
+		}
+		if record[fields["route_type"]] == "1000" {
+			record[fields["route_type"]] = "4"
+		}
+		err = w.Write(record)
+		if err != nil {
+			return fmt.Errorf("while writing a route record: %w", err)
+		}
+	}
+	w.Flush()
+	err = os.Remove(filepath.Join(path, "routes.txt"))
+	if err != nil {
+		return fmt.Errorf("while removing routes: %w", err)
+	}
+	err = os.Rename(filepath.Join(path, "routes2.txt"), filepath.Join(path, "routes.txt"))
+	if err != nil {
+		return fmt.Errorf("while renaming routes: %w", err)
+	}
+	return nil
+}




diff --git a/traffic/convert.go b/traffic/convert.go
index a2d2e55a70394e2b2aec6eb0200d104c70b23829..9d037ca66f577b5d0875fc32ea8f5fedaed521ec 100644
--- a/traffic/convert.go
+++ b/traffic/convert.go
@@ -7,6 +7,8 @@ // todo Agency.phoneNumber -> E.123 format
 // todo remove StopOrder.tripID
 // todo validity to FeedInfo, not filename
 
+// FIXME LineName is not unique
+
 import (
 	"apiote.xyz/p/szczanieckiej/config"
 	"apiote.xyz/p/szczanieckiej/file"
@@ -716,6 +718,13 @@ 		if err != nil {
 			return c, fmt.Errorf("while reading a record: %w", err)
 		}
 
+		if f, ok := fields["location_type"]; ok && record[f] != "" && record[f] != "0" {
+			// NOTE for now ignore everything that’s not a stop/platform
+			// TODO use Portals (location_type == 2) to show on map if platform has a parent (location_type == 1) that has a Portal
+			// TODO use location_type in {3,4} for routing inside stations (with pathways, transfers, and levels)
+			continue
+		}
+
 		stopID := record[fields["stop_id"]]
 
 		stopTrips := tripsThroughStop[stopID]
@@ -726,13 +735,14 @@ 		}
 
 		stop.Id = stopID
 
-		templates := []string{"stop_code", "stop_id", "stop_name"}
+		templates := []string{"stop_code", "stop_id", "stop_name", "platform_code"}
 		stop.Code = c.Feed.Flags().StopIdFormat
 		for _, template := range templates {
 			stop.Code = strings.Replace(stop.Code, "{{"+template+"}}", record[fields[template]], -1)
 		}
 		stop.Name = c.Feed.Flags().StopName
 		for _, template := range templates {
+			// TODO if '{{template}}' is empty
 			stop.Name = strings.Replace(stop.Name, "{{"+template+"}}", record[fields[template]], -1)
 		}
 		if field, ok := fields["zone_id"]; ok {




diff --git a/traffic/feeds.go b/traffic/feeds.go
index 141547e237448e2c9aac822a738e379345f6388f..0c2ba66c8c8ba7616414af236adf2b317cc44556 100644
--- a/traffic/feeds.go
+++ b/traffic/feeds.go
@@ -78,6 +78,7 @@ 			},
 		},
 		"krakow_ztp": ZtpKrakow{},
 		"gzm_ztm":    GzmZtm{},
+		"berlin_vbb": VbbBerlin{},
 	}
 }
 




diff --git a/transformers/de.go b/transformers/de.go
new file mode 100644
index 0000000000000000000000000000000000000000..92cdff2436be901d1c8bdda1010b3c6f41b11f15
--- /dev/null
+++ b/transformers/de.go
@@ -0,0 +1,29 @@
+package transformers
+
+import (
+	"golang.org/x/text/transform"
+)
+
+//nolint:gochecknoglobals
+var TransformerDE transform.Transformer = Replace(func(r rune) []rune {
+	switch r {
+	case 'ö':
+		return []rune{'o', 'e'}
+	case 'Ö':
+		return []rune{'O', 'e'}
+	case 'ä':
+		return []rune{'a', 'e'}
+	case 'Ä':
+		return []rune{'A', 'e'}
+	case 'ß':
+		return []rune{'s', 's'}
+	case 'ẞ':
+		return []rune{'S', 'S'}
+	case 'ü':
+		return []rune{'u', 'e'}
+	case 'Ü':
+		return []rune{'U', 'e'}
+	default:
+		return []rune{r}
+	}
+})




diff --git a/transformers/fr.go b/transformers/fr.go
new file mode 100644
index 0000000000000000000000000000000000000000..abfad31a9abd6c132b9d6dbbfb421c16732d2b05
--- /dev/null
+++ b/transformers/fr.go
@@ -0,0 +1,21 @@
+package transformers
+
+import (
+	"golang.org/x/text/transform"
+)
+
+//nolint:gochecknoglobals
+var TransformerFR transform.Transformer = Replace(func(r rune) []rune {
+	switch r {
+	case 'è':
+		return []rune{'e'}
+	case 'È':
+		return []rune{'E'}
+	case 'é':
+		return []rune{'e'}
+	case 'É':
+		return []rune{'E'}
+	default:
+		return []rune{r}
+	}
+})